fix(web): expose organize recovery and file episode evidence
This commit is contained in:
1 parent
b7883eaf4f
commit
2b3ccb89e4
12 files changed
+390
-72
No files matched your search
@@ -0,0 +1,70 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { PublicOrganizationRecord, RecordAttention } from "@umt/contracts";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { OrganizeRecordSheet } from "./organize-record-sheet.tsx";
|
||||
|
||||
const state = vi.hoisted(() => ({ record: null as PublicOrganizationRecord | null }));
|
||||
vi.mock("~/api/records.ts", async (original) => ({
|
||||
...(await original<typeof import("~/api/records.ts")>()),
|
||||
useRecord: () => ({ data: state.record }),
|
||||
}));
|
||||
vi.mock("~/components/console/detail-sheet.tsx", () => ({
|
||||
DetailSheet: ({ actions, children }: { actions: ReactNode; children: ReactNode }) =>
|
||||
createElement("div", null, actions, children),
|
||||
DetailSection: ({ children }: { children: ReactNode }) => createElement("section", null, children),
|
||||
}));
|
||||
vi.mock("~/components/organize/organize-scrape-history.tsx", () => ({ OrganizeScrapeHistory: () => null }));
|
||||
vi.mock("~/components/organize/delete-records-dialog.tsx", () => ({ DeleteRecordsDialog: () => null }));
|
||||
const render = (attention: RecordAttention | null) => {
|
||||
state.record = {
|
||||
id: "record",
|
||||
media: null,
|
||||
status: "failed",
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
sizeBytes: null,
|
||||
attention,
|
||||
reason: null,
|
||||
source: { path: "/input" },
|
||||
target: { path: null },
|
||||
counts: { total: 0, succeeded: 0, failed: 0, skipped: 0 },
|
||||
actor: { type: "system" },
|
||||
files: [],
|
||||
} as unknown as PublicOrganizationRecord;
|
||||
return renderToStaticMarkup(
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client: new QueryClient() },
|
||||
createElement(MemoryRouter, null, createElement(OrganizeRecordSheet, { id: "record", onClose: () => {} })),
|
||||
),
|
||||
);
|
||||
};
|
||||
it("记录问题区承担主动作,不重复给重新整理或泛化排查", () => {
|
||||
const html = render({ topic: "other", decision: null, agentIdentify: false });
|
||||
expect(html.match(/重新整理<\/button>/g)).toHaveLength(1);
|
||||
expect(html).not.toContain("让智能体排查");
|
||||
expect(render(null)).toContain("重新整理");
|
||||
});
|
||||
it("调查中只显示会话入口,不用另一套泛化排查覆盖进度", () => {
|
||||
const html = render({
|
||||
topic: "other",
|
||||
agentIdentify: false,
|
||||
decision: {
|
||||
id: "decision",
|
||||
kind: "other",
|
||||
status: "investigating",
|
||||
title: "排查",
|
||||
summary: null,
|
||||
options: [],
|
||||
evidence: {},
|
||||
agentRunId: "run",
|
||||
},
|
||||
});
|
||||
expect(html).toContain("智能体处理中");
|
||||
expect(html).toContain("/console/agent/run");
|
||||
expect(html).not.toContain("重新整理");
|
||||
expect(html).not.toContain("让智能体排查");
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { PublicOrganizationFile, PublicPathRef } from "@umt/contracts";
|
||||
import { ArrowRight, Bot, ChevronRight, Trash2, Wand2 } from "lucide-react";
|
||||
import { ArrowRight, ChevronRight, Trash2, Wand2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ACTION_LABEL, RECORD_STATUS_LABEL, RECORD_STATUS_TONE, TRIGGER_LABEL, useRecord } from "~/api/records.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
@@ -155,29 +154,12 @@ export function OrganizeRecordSheet({ id, onClose }: { id: string | null; onClos
|
||||
actions={
|
||||
r ? (
|
||||
<>
|
||||
{canReorganize(r) ? (
|
||||
{canReorganize(r) && !r.attention ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setReorganize(true)}>
|
||||
<Wand2 />
|
||||
重新整理
|
||||
</Button>
|
||||
) : null}
|
||||
{r.status === "failed" || r.status === "partial" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openAgentDrawer({
|
||||
label: `整理记录:${r.media?.title ?? "未关联作品"}`,
|
||||
recordId: r.id,
|
||||
entityId: r.media?.entityId,
|
||||
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Bot />
|
||||
让智能体排查
|
||||
</Button>
|
||||
) : null}
|
||||
{r.media ? (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to={consolePath("library", r.media.entityId)}>查看作品</Link>
|
||||
|
||||
@@ -35,7 +35,8 @@ const decision = (over: Partial<NonNullable<RecordAttention["decision"]>> = {})
|
||||
...over,
|
||||
});
|
||||
|
||||
type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attention" | "reason">;
|
||||
type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attention" | "reason"> &
|
||||
Partial<Pick<PublicOrganizationRecord, "media">>;
|
||||
const rec = (attention: RecordAttention | null, over: Partial<Rec> = {}): Rec => ({
|
||||
id: R,
|
||||
action: "copy",
|
||||
@@ -128,3 +129,39 @@ describe("整理记录行内处理", () => {
|
||||
expect(attentionLine(rec(null))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("季集问题直接跳已有作品映射,部分整理也读排除文件证据", () => {
|
||||
const media = {
|
||||
entityId: R,
|
||||
title: "Foo",
|
||||
year: null,
|
||||
kind: "series" as const,
|
||||
contentUnitId: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
externalIds: [],
|
||||
};
|
||||
const a: RecordAttention = { topic: "other", decision: null, agentIdentify: false };
|
||||
const r = rec(a, { media, reason: { code: "episode_unresolved", message: "集数待确认" } });
|
||||
expect(primaryAction(r)).toBe("mapping");
|
||||
expect(html(r)).toContain(`/console/library/${R}#episode-mapping`);
|
||||
expect(html(r)).toContain("处理集数映射");
|
||||
const partial = rec(
|
||||
{
|
||||
...a,
|
||||
decision: decision({
|
||||
kind: "other",
|
||||
evidence: { files: [{ path: "/E13.mkv", reason: "excluded:episode_unresolved" }] },
|
||||
}),
|
||||
},
|
||||
{ media, status: "partial", reason: { code: "excluded_unresolved", message: "还有文件待处理" } },
|
||||
);
|
||||
expect(primaryAction(partial)).toBe("mapping");
|
||||
expect(primaryAction(rec(a, { reason: r.reason }))).toBe("reorganize");
|
||||
expect(primaryAction(rec(a, { media, reason: { code: "naming_failed", message: "命名失败" } }))).toBe("reorganize");
|
||||
expect(
|
||||
html(
|
||||
rec({ ...a, decision: decision({ kind: "episode_mapping", status: "investigating", agentRunId: D }) }, { media }),
|
||||
),
|
||||
).not.toContain("处理集数映射");
|
||||
});
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
OptionButton,
|
||||
plainOptions,
|
||||
} from "~/components/decisions/decision-actions.tsx";
|
||||
import { mappingHref } from "~/components/episode-mapping/model.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { humanizeReason } from "~/lib/reasons.ts";
|
||||
import { humanizeReason, reasonCode } from "~/lib/reasons.ts";
|
||||
import { canReorganize } from "./record-parts.ts";
|
||||
|
||||
// 整理记录「需要处理」的行内处理(2026-10-06 去掉待处理中心后,整理侧的问题在这里就地处理):
|
||||
@@ -21,7 +22,20 @@ import { canReorganize } from "./record-parts.ts";
|
||||
// 目标已存在 / 路径冲突 → 事项给的选项(替换 / 保留两者 / 跳过);
|
||||
// 其余失败 / 部分完成 → 重新整理 / 忽略。执行都走已有端点(/decisions/{id}/resolve、整理向导、忽略)。
|
||||
|
||||
type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attention" | "reason">;
|
||||
type Rec = Pick<PublicOrganizationRecord, "id" | "action" | "status" | "attention" | "reason"> &
|
||||
Partial<Pick<PublicOrganizationRecord, "media">>;
|
||||
|
||||
/** 原因码和排除文件证据识别季集问题,直接进入已有作品映射分区。 */
|
||||
export function episodeMappingTarget(r: Rec): string | null {
|
||||
if (!r.media) return null;
|
||||
const files = r.attention?.decision?.evidence.files;
|
||||
const episodeIssue =
|
||||
reasonCode(r.reason) === "episode_unresolved" ||
|
||||
r.attention?.decision?.kind === "episode_mapping" ||
|
||||
(Array.isArray(files) &&
|
||||
files.some((f) => f?.reason === "excluded:episode_unresolved" || f?.reason === "episode_unresolved"));
|
||||
return episodeIssue ? mappingHref(consolePath("library", r.media.entityId)) : null;
|
||||
}
|
||||
|
||||
/** 一句话说清这条记录卡在哪(事项摘要优先,其次记录原因) */
|
||||
export function attentionLine(r: Rec): string | null {
|
||||
@@ -34,16 +48,18 @@ export function attentionLine(r: Rec): string | null {
|
||||
const suggestion = typeof recovery?.suggestion === "string" ? recovery.suggestion : a.decision.summary;
|
||||
return suggestion ? `需要你确认:${humanizeReason(suggestion)}` : "智能体暂时无法确定,需要你确认";
|
||||
}
|
||||
if (episodeMappingTarget(r)) return "季集对不上作品季表,到作品的集数映射里处理";
|
||||
if (a.topic === "identity") return "没识别出作品。指定作品,或让智能体识别";
|
||||
return a.decision?.summary ? humanizeReason(a.decision.summary) : null;
|
||||
}
|
||||
|
||||
/** 推荐的主动作(给列表文字提示与测试用):identity → 指定作品;conflict → 事项选项;other → 重新整理 */
|
||||
export function primaryAction(r: Rec): "assign" | "options" | "reorganize" | null {
|
||||
export function primaryAction(r: Rec): "assign" | "options" | "mapping" | "reorganize" | null {
|
||||
const a = r.attention;
|
||||
if (!a) return null;
|
||||
if (a.topic === "identity" && a.decision && canAssignWork(a.decision)) return "assign";
|
||||
if (a.topic === "conflict" && a.decision && plainOptions(a.decision).length) return "options";
|
||||
if (episodeMappingTarget(r)) return "mapping";
|
||||
return canReorganize(r) ? "reorganize" : null;
|
||||
}
|
||||
|
||||
@@ -90,6 +106,14 @@ export function RecordAttentionActions({
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{primary === "mapping" && !investigating ? (
|
||||
<Button asChild size="sm">
|
||||
<Link to={episodeMappingTarget(r)!}>
|
||||
<Wand2 />
|
||||
处理集数映射
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{primary === "assign" && d && !investigating ? <AssignWorkButton d={d} /> : null}
|
||||
{primary === "options" && d && !investigating
|
||||
? plainOptions(d).map((o, i) => (
|
||||
|
||||
Reference in new issue
Block a user