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]>
This commit is contained in:
1 parent
081db4651c
commit
9489e40e0c
23 files changed
+2615
-323
No files matched your search
@@ -4,6 +4,7 @@ import {
|
||||
type EnrichApplyInput,
|
||||
EnrichApplyResult,
|
||||
type EnrichRequest,
|
||||
EnrichStats,
|
||||
ExternalIdentity,
|
||||
type IdentityCandidateReason,
|
||||
type IdentityCandidateStatus,
|
||||
@@ -28,6 +29,7 @@ export const identityKeys = {
|
||||
identities: (f: IdentityFilters) => ["identity", "external", f] as const,
|
||||
revisions: (id: string) => ["identity", "revisions", id] as const,
|
||||
aliases: (f: AliasFilters) => ["identity", "aliases", f] as const,
|
||||
enrichStats: (days: number) => ["identity", "enrich-stats", days] as const,
|
||||
};
|
||||
|
||||
export type PoolFilters = { status?: IdentityCandidateStatus; reason?: IdentityCandidateReason; q?: string };
|
||||
@@ -159,6 +161,15 @@ export function useDeleteAlias() {
|
||||
});
|
||||
}
|
||||
|
||||
/** 元数据补全命中率(最近 N 天,按来源类别;META-2) */
|
||||
export function useEnrichStats(days = 30) {
|
||||
return useQuery({
|
||||
queryKey: identityKeys.enrichStats(days),
|
||||
queryFn: ({ signal }) => getParsed(EnrichStats, "/identity/enrich-stats", { days }, signal),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** 元数据补全建议(metadata.enrich;不改作品,同一标题 7 天内返回上次结果) */
|
||||
export function useEnrichPool() {
|
||||
return useMutation({
|
||||
|
||||
@@ -10,13 +10,21 @@ 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 { 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 };
|
||||
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;
|
||||
@@ -69,6 +77,12 @@ function FieldRow({ k, s, enrichmentId }: { k: EnrichFieldName; s: Suggestion; e
|
||||
<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>
|
||||
@@ -84,6 +98,7 @@ const FIELDS: EnrichFieldName[] = [
|
||||
"year",
|
||||
"platform",
|
||||
"episodeCount",
|
||||
"cast",
|
||||
"genre",
|
||||
"synopsis",
|
||||
"posterUrl",
|
||||
@@ -134,7 +149,8 @@ export function EnrichSection({ item }: { item: IdentityPoolItem }) {
|
||||
>
|
||||
{!out ? (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
TMDB 查不到时,按标题搜索网页,只读平台和百科网站,抽取字段并附出处。先看建议,确认后才建作品。
|
||||
TMDB
|
||||
查不到时,按标题搜索网页:读平台和百科网站,其它网站只用搜索摘要,抽取字段并附出处和置信度。先看建议,确认后才建作品。
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -162,6 +178,7 @@ export function EnrichSection({ item }: { item: IdentityPoolItem }) {
|
||||
<SourceLink url={x.url} />
|
||||
<span>
|
||||
{x.fetched ? "已读取" : x.skipped ? (ENRICH_SKIPPED[x.skipped] ?? x.skipped) : "未读取"}
|
||||
{x.snippetUsed ? ",用了搜索摘要" : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -281,6 +281,16 @@ export const ENRICH_FIELD_LABEL: Record<string, string> = {
|
||||
synopsis: "简介",
|
||||
posterUrl: "海报",
|
||||
genre: "类型",
|
||||
cast: "主演",
|
||||
};
|
||||
|
||||
/** 补全来源类别(META-2) */
|
||||
export const ENRICH_SOURCE_KIND: Record<string, string> = {
|
||||
encyclopedia: "百科",
|
||||
platform: "平台页",
|
||||
douban: "豆瓣摘要",
|
||||
snippet: "搜索摘要",
|
||||
other: "其它网站",
|
||||
};
|
||||
|
||||
/** 补全来源链接被跳过的原因 */
|
||||
@@ -290,6 +300,9 @@ export const ENRICH_SKIPPED: Record<string, string> = {
|
||||
irrelevant: "标题对不上",
|
||||
limit: "超出读取条数",
|
||||
error: "读取失败",
|
||||
snippet_only: "只用搜索摘要",
|
||||
robots: "网站不允许抓取",
|
||||
blocked: "要求登录或验证",
|
||||
};
|
||||
|
||||
export const UNIT_KIND: Record<string, string> = { season: "季", episode: "集", special: "特别篇", part: "分段" };
|
||||
|
||||
@@ -246,14 +246,19 @@ export function CreditsStrip({ cast, crew }: { cast: MediaCredit[]; crew: MediaC
|
||||
{people.map((p) => (
|
||||
<li key={p.key} className="flex w-20 shrink-0 flex-col items-center text-center">
|
||||
<Avatar path={p.profilePath} name={p.name} />
|
||||
<a
|
||||
href={`https://www.themoviedb.org/person/${p.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="mt-1.5 line-clamp-2 text-meta font-medium break-words text-foreground hover:underline"
|
||||
>
|
||||
{p.name}
|
||||
</a>
|
||||
{p.id > 0 ? (
|
||||
<a
|
||||
href={`https://www.themoviedb.org/person/${p.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="mt-1.5 line-clamp-2 text-meta font-medium break-words text-foreground hover:underline"
|
||||
>
|
||||
{p.name}
|
||||
</a>
|
||||
) : (
|
||||
// 元数据补全写的主演没有 TMDB 人物 ID(负数占位),不链接
|
||||
<span className="mt-1.5 line-clamp-2 text-meta font-medium break-words text-foreground">{p.name}</span>
|
||||
)}
|
||||
{p.role ? <span className="line-clamp-2 text-micro break-words text-muted-foreground">{p.role}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { DEFAULT_SETTINGS } from "@umt/contracts";
|
||||
import { toast } from "sonner";
|
||||
import { useClearImageCache, useClearTmdbCache, useImageCache, useTmdbCache } from "~/api/cache.ts";
|
||||
import { useEnrichStats } from "~/api/identity.ts";
|
||||
import { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { ENRICH_SOURCE_KIND } from "~/components/library/labels.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { secretPatch } from "~/lib/secret.ts";
|
||||
@@ -212,7 +215,14 @@ const IDENTITY_CARD: CardSpec = {
|
||||
name: "identity.enrichDomains",
|
||||
label: "补全可读取的网站",
|
||||
kind: "textarea",
|
||||
hint: "每行一个域名,含子域名;只放平台和百科类网站。已登记站点的域名一律不读",
|
||||
hint: "每行一个域名,含子域名;只放平台和百科类网站。豆瓣只用搜索摘要,不读页面;robots.txt 不允许的页面不读。已登记站点的域名一律不读",
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
name: "identity.enrichSnippetAllSites",
|
||||
label: "也用其它网站的搜索摘要",
|
||||
kind: "switch",
|
||||
hint: "短剧多半只有聚合站收录。打开后,白名单以外网站的搜索摘要也用来抽年份、集数、主演、简介(只看搜索引擎给的摘要,不打开这些网页)",
|
||||
wide: true,
|
||||
},
|
||||
],
|
||||
@@ -221,6 +231,7 @@ const IDENTITY_CARD: CardSpec = {
|
||||
"identity.autoEnrich": s.identity.autoEnrich,
|
||||
"identity.enrichDailyBudget": n(s.identity.enrichDailyBudget),
|
||||
"identity.enrichDomains": s.identity.enrichDomains.join("\n"),
|
||||
"identity.enrichSnippetAllSites": s.identity.enrichSnippetAllSites ?? true,
|
||||
}),
|
||||
toPatch: (v) => ({
|
||||
identity: {
|
||||
@@ -231,10 +242,59 @@ const IDENTITY_CARD: CardSpec = {
|
||||
.split(/[\s,,]+/)
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
enrichSnippetAllSites: v["identity.enrichSnippetAllSites"] === true,
|
||||
},
|
||||
}),
|
||||
extra: () => <EnrichStatsTable />,
|
||||
};
|
||||
|
||||
type StatRow = { kind: string; withResults: number; hits: number; rate: number | null };
|
||||
|
||||
/** 最近 30 天补全命中率(按来源类别,META-2) */
|
||||
function EnrichStatsTable() {
|
||||
const q = useEnrichStats(30);
|
||||
const d = q.data;
|
||||
return (
|
||||
<div className="mb-3 space-y-2">
|
||||
<p className="text-meta text-muted-foreground tabular-nums">
|
||||
{d
|
||||
? `最近 ${d.days} 天元数据补全 ${d.total} 次,出了建议 ${d.suggested} 次${d.tracked < d.total ? `;有来源明细的 ${d.tracked} 次` : ""}`
|
||||
: "读取补全命中率…"}
|
||||
</p>
|
||||
{d && d.tracked > 0 ? (
|
||||
<DataTable<StatRow>
|
||||
rows={d.sources}
|
||||
rowKey={(r) => r.kind}
|
||||
empty={{ title: "还没有补全记录" }}
|
||||
columns={[
|
||||
{ key: "kind", header: "来源", cell: (r) => ENRICH_SOURCE_KIND[r.kind] ?? r.kind },
|
||||
{
|
||||
key: "withResults",
|
||||
header: "有结果",
|
||||
align: "right",
|
||||
cell: (r) => <span className="tabular-nums">{r.withResults}</span>,
|
||||
},
|
||||
{
|
||||
key: "hits",
|
||||
header: "命中",
|
||||
align: "right",
|
||||
cell: (r) => <span className="tabular-nums">{r.hits}</span>,
|
||||
},
|
||||
{
|
||||
key: "rate",
|
||||
header: "命中率",
|
||||
align: "right",
|
||||
cell: (r) => (
|
||||
<span className="tabular-nums">{r.rate === null ? "—" : `${Math.round(r.rate * 100)}%`}</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetadataSection() {
|
||||
return (
|
||||
<SettingsGate>
|
||||
|
||||
Reference in new issue
Block a user