feat(identity): lazy TMDB lookup and bounded identity pool (DEC-1)
RSS/list releases that match no subscription or local work no longer hit
TMDB or open a pending item; they go to the identity pool as `unmatched`.
TMDB is queried only on a local alias hit, a published new-title rule whose
prefilter passes, manual/Agent retry, search results, or when the new
setting identity.eagerTmdbLookup (default off) is on. Mirrors MoviePilot:
torrents are recognised only when matched to a subscription or searched,
and recognition failures are logged, not alerted.
- Pending items only for related releases (ambiguous after lookup, or
related to a local work but unresolved); same source + normalized title
merge into one decision ("来源「X」的 N 条资源待识别"), resolve/dismiss
applies to the whole group, members detach as they get resolved.
- Pool governance: unmatched reviews capped (daily, 3x), title_alias
insert trigger wakes similar entries, stale unmatched entries archived
after identity.poolArchiveDays (30); review idempotency keys include the
due time.
- Migration 0047_identity_pool: reason/outcome `unmatched`,
identity_candidate.normalized_title (+trgm index), wake trigger, and
cleanup of open identity_unresolved items with no related work
(dismissed, resolution=moved_to_pool).
- Inbox: group by type + work/source with collapse, hide info-only items
by default (minSeverity=warning), badge counts warning+ only; pool sheet
gets manual TMDB/local-work assign and dismiss; settings card for the
new identity settings.
- Runbook docs/runbook/identity.md.
Requires rebuild/restart (migration runs on start).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
524af64bec
commit
94fd48bff2
24 files changed
+1326
-153
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts";
|
||||
import { BellOff, Bot, CheckCheck, ChevronRight, Inbox, Radio, RotateCw } from "lucide-react";
|
||||
import { BellOff, Bot, CheckCheck, ChevronDown, ChevronRight, Inbox, Radio, RotateCw } from "lucide-react";
|
||||
import { type ReactNode, useId, useMemo, useRef, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
@@ -26,8 +26,10 @@ import { actorText } from "~/lib/labels.ts";
|
||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 待处理(REQ §13.1):按作品与问题类型合并;每项给证据、建议、影响范围与预览;
|
||||
// 待处理(REQ §13.1):按问题类型 + 作品 / 来源合并并可折叠;每项给证据、建议、影响范围与预览;
|
||||
// 处理选项来自后端 decision.options;SSE 收到 decision.* 事件即刷新。
|
||||
// 缺省只看需要你处理的事项(警告及以上),"仅信息"类在"级别"筛选里展开。身份类事项后端已按来源 + 标题合并,
|
||||
// 卡片里列出组内资源,选候选 / 指定 / 忽略对整组生效。
|
||||
|
||||
const OPEN: DecisionStatus[] = ["open", "investigating", "awaiting_user"];
|
||||
const STATUS_FILTER = [
|
||||
@@ -36,9 +38,55 @@ const STATUS_FILTER = [
|
||||
{ value: "expired", label: "已过期" },
|
||||
];
|
||||
const SEV_RANK = { info: 0, warning: 1, critical: 2 } as const;
|
||||
const SEV_FILTER = [{ value: "all", label: "含仅信息" }];
|
||||
/** 缺省展开的组数;其余折叠 */
|
||||
const OPEN_GROUPS = 3;
|
||||
|
||||
/** 证据里约定俗成的字段(Agent/后端写入);其余原样放进"原始证据" */
|
||||
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v : null);
|
||||
/** 身份类合并事项的来源分组(后端 evidence.group) */
|
||||
function sourceGroupOf(d: Decision): { siteId: string; siteName: string | null } | null {
|
||||
const g = d.evidence.group as { siteId?: unknown; siteName?: unknown } | undefined;
|
||||
return g && typeof g.siteId === "string" ? { siteId: g.siteId, siteName: str(g.siteName) } : null;
|
||||
}
|
||||
|
||||
/** 合并事项的组内资源 */
|
||||
function GroupReleases({ d }: { d: Decision }) {
|
||||
const list = Array.isArray(d.evidence.releases)
|
||||
? (d.evidence.releases as { sourceReleaseId?: string; title?: string }[])
|
||||
: [];
|
||||
const count = typeof d.evidence.count === "number" ? d.evidence.count : list.length;
|
||||
const [open, setOpen] = useState(false);
|
||||
if (count <= 1) return null;
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex cursor-pointer items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
{open ? "收起" : "展开"}组内 {count} 条资源
|
||||
</button>
|
||||
{open ? (
|
||||
<ul className="mt-1.5 space-y-1 border-l border-border/60 pl-3">
|
||||
{list.map((r) => (
|
||||
<li key={r.sourceReleaseId} className="font-mono text-meta break-all text-muted-foreground">
|
||||
{r.title}
|
||||
</li>
|
||||
))}
|
||||
{count > list.length ? (
|
||||
<li className="text-meta text-muted-foreground">
|
||||
另有 {count - list.length} 条,可在媒体概览 → 待识别按标题检索
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function entityOf(d: Decision): string | null {
|
||||
const m = d.evidence.mediaEntityId;
|
||||
if (typeof m === "string" && /^[0-9a-f-]{36}$/.test(m)) return m;
|
||||
@@ -240,6 +288,7 @@ function DecisionCard({ d }: { d: Decision }) {
|
||||
</div>
|
||||
<h3 className="mt-2 text-body font-medium break-words text-foreground">{d.title}</h3>
|
||||
{d.summary ? <p className="mt-1 text-caption break-words text-muted-foreground">{d.summary}</p> : null}
|
||||
<GroupReleases d={d} />
|
||||
<div className="mt-3">
|
||||
<Evidence d={d} />
|
||||
</div>
|
||||
@@ -272,12 +321,20 @@ function DecisionCard({ d }: { d: Decision }) {
|
||||
);
|
||||
}
|
||||
|
||||
type Group = { key: string; entityId: string | null; kind: DecisionKind; items: Decision[] };
|
||||
type Group = {
|
||||
key: string;
|
||||
entityId: string | null;
|
||||
source: { siteId: string; siteName: string | null } | null;
|
||||
kind: DecisionKind;
|
||||
items: Decision[];
|
||||
};
|
||||
|
||||
export default function InboxPage() {
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const status = (sp.get("status") ?? "") as DecisionStatus | "";
|
||||
const kind = (sp.get("kind") ?? "") as DecisionKind | "";
|
||||
const sev = sp.get("sev") ?? "";
|
||||
const minSeverity = sev === "all" ? undefined : "warning";
|
||||
const set = (k: string, v: string) =>
|
||||
setSp(
|
||||
(prev) => {
|
||||
@@ -289,8 +346,8 @@ export default function InboxPage() {
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
const summary = useDecisionSummary(status || undefined);
|
||||
const list = useDecisions({ status: status || undefined, kind: kind || undefined });
|
||||
const summary = useDecisionSummary(status || undefined, minSeverity);
|
||||
const list = useDecisions({ status: status || undefined, kind: kind || undefined, minSeverity });
|
||||
const rows = list.data?.pages.flatMap((p) => p.items);
|
||||
|
||||
// SSE:decision.created / decision.updated → 刷新(合并 500ms 内的多条事件)
|
||||
@@ -324,13 +381,14 @@ export default function InboxPage() {
|
||||
return [...m.entries()].sort((a, b) => SEV_RANK[b[1].sev] - SEV_RANK[a[1].sev] || b[1].count - a[1].count);
|
||||
}, [summary.data]);
|
||||
|
||||
// 按作品 + 问题类型合并;组内按严重度、时间
|
||||
// 按问题类型 + 作品 / 来源合并;组内按严重度、时间
|
||||
const groups = useMemo(() => {
|
||||
const m = new Map<string, Group>();
|
||||
for (const d of rows ?? []) {
|
||||
const entityId = entityOf(d);
|
||||
const key = `${entityId ?? `subject:${d.subjectType ?? "none"}`}|${d.kind}`;
|
||||
const g = m.get(key) ?? { key, entityId, kind: d.kind, items: [] };
|
||||
const source = entityId ? null : sourceGroupOf(d);
|
||||
const key = `${entityId ?? (source ? `site:${source.siteId}` : `subject:${d.subjectType ?? "none"}`)}|${d.kind}`;
|
||||
const g = m.get(key) ?? { key, entityId, source, kind: d.kind, items: [] };
|
||||
g.items.push(d);
|
||||
m.set(key, g);
|
||||
}
|
||||
@@ -339,6 +397,9 @@ export default function InboxPage() {
|
||||
}, [rows]);
|
||||
|
||||
const isOpenView = !status;
|
||||
// 折叠状态:缺省前 OPEN_GROUPS 组展开;用户点过的组按用户的来
|
||||
const [toggled, setToggled] = useState<Record<string, boolean>>({});
|
||||
const isExpanded = (key: string, index: number) => toggled[key] ?? index < OPEN_GROUPS;
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -388,7 +449,7 @@ export default function InboxPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FilterBar onReset={status || kind ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}>
|
||||
<FilterBar onReset={status || kind || sev ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={status}
|
||||
@@ -397,6 +458,7 @@ export default function InboxPage() {
|
||||
allLabel="未关闭"
|
||||
/>
|
||||
<FilterSelect label="问题类型" value={kind} onChange={(v) => set("kind", v)} options={opts(DECISION_KIND)} />
|
||||
<FilterSelect label="级别" value={sev} onChange={(v) => set("sev", v)} options={SEV_FILTER} allLabel="需处理" />
|
||||
</FilterBar>
|
||||
|
||||
{list.isPending ? (
|
||||
@@ -413,15 +475,27 @@ export default function InboxPage() {
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map((g) => {
|
||||
{groups.map((g, index) => {
|
||||
const title = g.entityId ? titles.get(g.entityId) : null;
|
||||
const expanded = isExpanded(g.key, index);
|
||||
const maxSev = g.items.reduce<keyof typeof SEV_RANK>(
|
||||
(a, d) => (SEV_RANK[d.severity] > SEV_RANK[a] ? d.severity : a),
|
||||
"info",
|
||||
);
|
||||
return (
|
||||
<section key={g.key} className="pg-slab min-w-0 overflow-hidden rounded-xl">
|
||||
<header className="flex flex-wrap items-center gap-2 border-b border-border/60 px-4 py-3 sm:px-5">
|
||||
<header
|
||||
className={`flex flex-wrap items-center gap-2 px-4 py-3 sm:px-5 ${expanded ? "border-b border-border/60" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setToggled((t) => ({ ...t, [g.key]: !expanded }))}
|
||||
className="inline-flex cursor-pointer items-center text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "折叠" : "展开"}
|
||||
>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
<StatusBadge tone={SEVERITY[maxSev].tone}>{DECISION_KIND[g.kind]}</StatusBadge>
|
||||
{g.entityId ? (
|
||||
<Link
|
||||
@@ -430,6 +504,10 @@ export default function InboxPage() {
|
||||
>
|
||||
{title ?? "关联作品"}
|
||||
</Link>
|
||||
) : g.source ? (
|
||||
<span className="min-w-0 truncate text-body font-semibold text-foreground">
|
||||
来源「{g.source.siteName ?? "未知站点"}」
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-body font-semibold text-muted-foreground">
|
||||
{g.kind === "identity_ambiguous" || g.kind === "identity_unresolved" ? "尚未归属作品" : "系统级"}
|
||||
@@ -437,9 +515,7 @@ export default function InboxPage() {
|
||||
)}
|
||||
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>
|
||||
</header>
|
||||
{g.items.map((d) => (
|
||||
<DecisionCard key={d.id} d={d} />
|
||||
))}
|
||||
{expanded ? g.items.map((d) => <DecisionCard key={d.id} d={d} />) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in new issue
Block a user