Files
UHD_Media_Tool/apps/web/app/components/library/identity-enrich.tsx
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

246 lines
9.5 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 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, ENRICH_SOURCE_KIND, 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;
sourceKind?: string;
confidence?: number;
hosts?: number;
};
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>智能体归纳</Chip>}
{v.sourceKind ? <Chip>{ENRICH_SOURCE_KIND[v.sourceKind] ?? v.sourceKind}</Chip> : null}
{v.confidence !== undefined ? (
<span className="text-meta tabular-nums text-muted-foreground">
置信度 {Math.round(v.confidence * 100)}%{v.hosts && v.hosts > 1 ? ` · ${v.hosts} 个网站` : ""}
</span>
) : null}
<SourceLink url={v.sourceUrl} />
</div>
</div>
))}
</div>
</li>
);
}
const FIELDS: EnrichFieldName[] = [
"title",
"aliases",
"year",
"platform",
"episodeCount",
"cast",
"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" ? `智能体抽取${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) : "未读取"}
{x.snippetUsed ? ",用了搜索摘要" : ""}
</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>
);
}