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:
rootandClaude Opus 5.5 committed 2026-09-29 15:57:48 +08:00
1 parent 18a5cf1de7
commit f4666a4671
36 files changed
+2514 -25

No files matched your search

@@ -0,0 +1,228 @@
import type { EnrichFieldName, IdentityPoolItem, MetadataEnrichOutput } from "@umt/contracts";
import { ExternalLink, LoaderCircle, Sparkles } from "lucide-react";
import { useId, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { enrichPosterUrl, useApplyEnrich, useEnrichPool } from "~/api/identity.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import { Label } from "~/components/ui/label.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { ENRICH_FIELD_LABEL, ENRICH_SKIPPED, EVIDENCE_LEVEL } from "./labels.ts";
import { Chip } from "./parts.tsx";
// META-1 待识别资源 → 元数据补全:先看建议(网页搜索 + 白名单网页 + Agent 抽取,不改作品),再应用(建本地临时作品并关联)。
type Suggestion = NonNullable<MetadataEnrichOutput["suggestion"]>;
type Value = { value: unknown; sourceUrl: string | null; evidenceLevel: string; verbatim: boolean };
const hostOf = (u: string | null) => {
if (!u) return null;
try {
return new URL(u).hostname.replace(/^www\./, "");
} catch {
return null;
}
};
function SourceLink({ url }: { url: string | null }) {
const host = hostOf(url);
if (!url || !host) return <span className="text-meta text-muted-foreground">无出处</span>;
return (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 text-meta break-all text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
{host}
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
);
}
function valuesOf(s: Suggestion, k: EnrichFieldName): Value[] {
const v = s.fields[k] as Value | Value[] | null;
return Array.isArray(v) ? v : v ? [v] : [];
}
function FieldRow({ k, s, enrichmentId }: { k: EnrichFieldName; s: Suggestion; enrichmentId: string }) {
const vals = valuesOf(s, k);
if (!vals.length) return null;
return (
<li className="flex flex-wrap items-start gap-x-3 gap-y-1 py-2 first:pt-0 last:pb-0">
<div className="w-16 shrink-0 pt-0.5 text-meta text-muted-foreground">{ENRICH_FIELD_LABEL[k] ?? k}</div>
<div className="min-w-0 flex-1 basis-48 space-y-1">
{k === "posterUrl" ? (
<img
src={enrichPosterUrl(enrichmentId)}
alt="海报"
className="h-32 w-auto rounded-md border border-border object-cover"
loading="lazy"
/>
) : null}
{vals.map((v, i) => (
<div key={i} className="min-w-0">
{k !== "posterUrl" ? <p className="text-sm break-words text-foreground">{String(v.value)}</p> : null}
<div className="flex flex-wrap items-center gap-1.5">
<Chip>{EVIDENCE_LEVEL[v.evidenceLevel] ?? v.evidenceLevel}</Chip>
{v.verbatim ? null : <Chip>Agent 归纳</Chip>}
<SourceLink url={v.sourceUrl} />
</div>
</div>
))}
</div>
</li>
);
}
const FIELDS: EnrichFieldName[] = [
"title",
"aliases",
"year",
"platform",
"episodeCount",
"genre",
"synopsis",
"posterUrl",
];
export function EnrichSection({ item }: { item: IdentityPoolItem }) {
const uid = useId();
const enrich = useEnrichPool();
const apply = useApplyEnrich();
const [out, setOut] = useState<MetadataEnrichOutput | null>(null);
const [lock, setLock] = useState(false);
const s = out?.suggestion ?? null;
const run = async (fresh: boolean) => {
try {
const r = await enrich.mutateAsync({ id: item.id, ...(fresh ? { fresh: true } : {}) });
setOut(r);
if (r.status === "failed") toast.error(r.error ?? "没有找到可用的信息");
} catch (err) {
toast.error(errorMessage(err, "补全失败"));
}
};
const doApply = async (mediaEntityId?: string) => {
if (!out) return;
try {
const r = await apply.mutateAsync({
id: item.id,
enrichmentId: out.enrichmentId,
lock,
...(mediaEntityId ? { mediaEntityId } : {}),
});
toast.success(r.created ? "已建临时作品并关联" : "已并入已有作品并关联");
setOut({ ...out, status: "applied", appliedMediaEntityId: r.mediaEntityId });
} catch (err) {
toast.error(errorMessage(err, "应用失败"));
}
};
return (
<DetailSection
title="元数据补全"
actions={
<Button variant="outline" size="sm" disabled={enrich.isPending} onClick={() => run(false)}>
{enrich.isPending ? <LoaderCircle className="animate-spin" /> : <Sparkles />}
{out ? "重新载入" : "补全"}
</Button>
}
>
{!out ? (
<p className="text-caption text-muted-foreground">
TMDB 查不到时,按标题搜索网页,只读平台和百科网站,抽取字段并附出处。先看建议,确认后才建作品。
</p>
) : (
<div className="space-y-3">
<p className="text-meta text-muted-foreground">
{out.notice}
{" · "}
{out.cached ? "7 天内的缓存结果" : `搜索 ${out.usage.searchCalls} 次,读取网页 ${out.usage.fetchCalls} 次`}
{s ? ` · ${s.extractor === "model" ? `Agent 抽取${s.model ? `(${s.model})` : ""}` : "规则抽取"}` : ""}
{` · 今日 ${out.budget.usedToday}/${out.budget.dailyLimit}`}
</p>
{out.error ? <p className="text-caption text-warning">{out.error}</p> : null}
{s ? (
<ul className="divide-y divide-border/60">
{FIELDS.map((k) => (
<FieldRow key={k} k={k} s={s} enrichmentId={out.enrichmentId} />
))}
</ul>
) : null}
{s?.sources.length ? (
<details className="text-meta text-muted-foreground">
<summary className="cursor-pointer">搜索到的链接({s.sources.length})</summary>
<ul className="mt-1.5 space-y-1">
{s.sources.map((x) => (
<li key={x.url} className="flex flex-wrap items-center gap-1.5">
<SourceLink url={x.url} />
<span>
{x.fetched ? "已读取" : x.skipped ? (ENRICH_SKIPPED[x.skipped] ?? x.skipped) : "未读取"}
</span>
</li>
))}
</ul>
</details>
) : null}
{out.appliedMediaEntityId ? (
<Button asChild variant="outline" size="sm">
<Link to={consolePath("library", out.appliedMediaEntityId)}>查看作品</Link>
</Button>
) : out.status === "suggested" && s ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Switch id={`${uid}-lock`} checked={lock} onCheckedChange={setLock} />
<Label htmlFor={`${uid}-lock`} className="text-caption">
应用的字段同时锁定
</Label>
</div>
{out.existing.length ? (
<div className="space-y-1.5">
<p className="text-caption text-muted-foreground">
已有同名作品。点「应用」时年份不冲突的自动并入,也可以指定一个:
</p>
{out.existing.map((e) => (
<div key={e.mediaEntityId} className="flex flex-wrap items-center gap-2">
<Link
to={consolePath("library", e.mediaEntityId)}
className="text-sm underline-offset-4 hover:underline"
>
{e.title}
{e.year ? ` (${e.year})` : ""}
</Link>
<Button
variant="outline"
size="sm"
disabled={apply.isPending}
onClick={() => doApply(e.mediaEntityId)}
>
并入
</Button>
</div>
))}
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={apply.isPending} onClick={() => doApply()}>
{apply.isPending ? <LoaderCircle className="animate-spin" /> : null}
应用
</Button>
<Button variant="ghost" size="sm" disabled={enrich.isPending} onClick={() => run(true)}>
重新搜索
</Button>
</div>
</div>
) : (
<Button variant="ghost" size="sm" disabled={enrich.isPending} onClick={() => run(true)}>
重新搜索
</Button>
)}
</div>
)}
</DetailSection>
);
}