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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user