|
|
@@ -1,9 +1,10 @@
|
|
|
"use client";
|
|
|
|
|
|
import Link from "next/link";
|
|
|
-import { ArrowRight, ExternalLink, Hash, ListVideo, Search, UserSearch } from "lucide-react";
|
|
|
+import { ExternalLink, Hash, Info, ListVideo, Search, UserSearch } from "lucide-react";
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
import { AppShell } from "@/components/AppShell";
|
|
|
+import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
|
|
|
import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
|
|
|
import { getFlowLedgerWalk } from "@/lib/api/client";
|
|
|
import type { FlowLedgerWalkResponse, RawRecord } from "@/lib/api/types";
|
|
|
@@ -31,6 +32,7 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
|
|
|
const [data, setData] = useState<FlowLedgerWalkResponse | null>(null);
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
+ const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
|
|
|
|
|
|
const load = useCallback(async () => {
|
|
|
setLoading(true);
|
|
|
@@ -67,7 +69,7 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
|
|
|
<span className="tag-mini">{tree.totalGroups ? `${tree.totalGroups} 个起点` : "没有继续扩展"}</span>
|
|
|
</summary>
|
|
|
<div className="decl-body">
|
|
|
- <Summary label="怎么看" value="每个色块是一条起点视频;视频下面缩进的路线,就是它向外游走的标签、作者或翻页;如果带回的视频又继续游走,会继续向内缩进。" />
|
|
|
+ <Summary label="怎么看" value="先看每次游走用了什么标签、作者或翻页;再看它带回的视频是否入池、待复看或淘汰。带回的视频如果继续游走,会在视频卡下面继续缩进展开。" />
|
|
|
<Summary label="本轮概况" value={`已执行 ${Number(data.summary.success_count || 0)} 条路线 · 未执行 ${Number(data.summary.skipped_count || 0)} 条路线 · 新增搜索词 ${Number(data.summary.extension_query_count || 0)} 个`} />
|
|
|
</div>
|
|
|
</details>
|
|
|
@@ -80,10 +82,11 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
|
|
|
</div>
|
|
|
<div className="walk-ledger-groups">
|
|
|
{tree.roots.map((group, index) => (
|
|
|
- <WalkGroupCard group={group} index={index} runId={runId} tree={tree} level={0} key={group.id} />
|
|
|
+ <WalkGroupCard group={group} index={index} runId={runId} tree={tree} level={0} onOpenScore={setDrawer} key={group.id} />
|
|
|
))}
|
|
|
</div>
|
|
|
</section>
|
|
|
+ <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
|
|
|
</>
|
|
|
) : null}
|
|
|
</AppShell>
|
|
|
@@ -105,6 +108,7 @@ function WalkGroupCard({
|
|
|
runId,
|
|
|
tree,
|
|
|
level,
|
|
|
+ onOpenScore,
|
|
|
seen = new Set<string>()
|
|
|
}: {
|
|
|
group: WalkGroup;
|
|
|
@@ -112,6 +116,7 @@ function WalkGroupCard({
|
|
|
runId: string;
|
|
|
tree: WalkTree;
|
|
|
level: number;
|
|
|
+ onOpenScore: (drawer: BusinessDrawerState) => void;
|
|
|
seen?: Set<string>;
|
|
|
}) {
|
|
|
const routeActions = group.actions.filter((action) => !isTerminalEdge(text(action.edge_id, "")));
|
|
|
@@ -150,6 +155,7 @@ function WalkGroupCard({
|
|
|
sourceId={group.id}
|
|
|
tree={tree}
|
|
|
level={level}
|
|
|
+ onOpenScore={onOpenScore}
|
|
|
seen={nextSeen}
|
|
|
key={`${text(action.id, "walk")}-${actionIndex}`}
|
|
|
/>
|
|
|
@@ -174,6 +180,7 @@ function WalkRouteCard({
|
|
|
sourceId,
|
|
|
tree,
|
|
|
level,
|
|
|
+ onOpenScore,
|
|
|
seen
|
|
|
}: {
|
|
|
action: RawRecord;
|
|
|
@@ -181,6 +188,7 @@ function WalkRouteCard({
|
|
|
sourceId: string;
|
|
|
tree: WalkTree;
|
|
|
level: number;
|
|
|
+ onOpenScore: (drawer: BusinessDrawerState) => void;
|
|
|
seen: Set<string>;
|
|
|
}) {
|
|
|
const edgeId = text(action.edge_id, "");
|
|
|
@@ -190,9 +198,8 @@ function WalkRouteCard({
|
|
|
const isSuccess = status === "success";
|
|
|
const routeName = routeLabel(edgeId);
|
|
|
const gate = gateText(action, isSuccess);
|
|
|
- const firstNode = routeTriggerNode(action);
|
|
|
- const secondNode = routeResultNode(action, targetQuery, targetVideos, isSuccess);
|
|
|
const childGroups = (tree.childrenByActionId.get(actionKey(action)) || []).filter((group) => !seen.has(group.id));
|
|
|
+ const childByVideoId = new Map(childGroups.map((group) => [group.id, group]));
|
|
|
|
|
|
return (
|
|
|
<article className={`walk-tree-route ${edgeTone(edgeId)} ${isSuccess ? "success" : "stopped"}`} id={`route-${encodeURIComponent(text(action.id, routeName))}`}>
|
|
|
@@ -202,21 +209,17 @@ function WalkRouteCard({
|
|
|
<span className="walk-edge-icon">{edgeIcon(edgeId)}</span>
|
|
|
<div>
|
|
|
<strong>{routeName}</strong>
|
|
|
- <p>{isSuccess ? successSentence(action, targetQuery, targetVideos) : stoppedSentence(action)}</p>
|
|
|
+ <p>{routeSummary(action, targetQuery, targetVideos, isSuccess)}</p>
|
|
|
</div>
|
|
|
<span className={`chip ${isSuccess ? "green" : "yellow"}`}>{isSuccess ? "已执行" : "未执行"}</span>
|
|
|
</div>
|
|
|
|
|
|
- <div className="walk-flow" aria-label={`${routeName} 的链路`}>
|
|
|
- <FlowNode label="从" value={text(action.from_label, "").includes("搜索词") ? "这个搜索词" : "这条视频"} href={`#source-${encodeURIComponent(sourceId)}`} />
|
|
|
- <FlowArrow />
|
|
|
- <FlowNode label={firstNode.label} value={firstNode.value} />
|
|
|
- <FlowArrow />
|
|
|
- <FlowNode label={secondNode.label} value={secondNode.value} href={secondNode.href} tone={isSuccess ? "strong" : "muted"} />
|
|
|
- </div>
|
|
|
-
|
|
|
<div className="walk-route-bottom">
|
|
|
<span className={`walk-gate ${isSuccess ? "pass" : "stop"}`}>{gate}</span>
|
|
|
+ <button className="walk-jump-button" type="button" onClick={() => onOpenScore(scoreDrawer(action, routeName, isSuccess))}>
|
|
|
+ <Info size={13} />
|
|
|
+ 看筛选口径
|
|
|
+ </button>
|
|
|
{targetQuery.id ? (
|
|
|
<Link className="walk-jump-button" href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(text(targetQuery.id, ""))}/videos`}>
|
|
|
查看带回视频
|
|
|
@@ -231,57 +234,89 @@ function WalkRouteCard({
|
|
|
<ListVideo size={15} />
|
|
|
<span>带回视频 {targetVideos.length} 条</span>
|
|
|
</div>
|
|
|
- {targetVideos.slice(0, 4).map((video) => (
|
|
|
- <div className="walk-result-video" key={text(video.id, text(video.title, "video"))}>
|
|
|
- <Link className="walk-result-video-main" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(text(video.id, ""))}`}>
|
|
|
- <b>{text(video.title, "无标题视频")}</b>
|
|
|
- <small>{text(video.author, "未知作者")} · {text(asRecord(video.decision).label, "未判断")}</small>
|
|
|
- </Link>
|
|
|
- <VideoLinks video={video} runId={runId} compact />
|
|
|
- </div>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- ) : null}
|
|
|
-
|
|
|
- {childGroups.length ? (
|
|
|
- <div className="walk-nested-children">
|
|
|
- <div className="walk-nested-label">带回的视频继续触发下一层游走</div>
|
|
|
- {childGroups.map((group, childIndex) => (
|
|
|
- <WalkGroupCard
|
|
|
- group={group}
|
|
|
- index={childIndex}
|
|
|
- runId={runId}
|
|
|
- tree={tree}
|
|
|
- level={level + 1}
|
|
|
- seen={seen}
|
|
|
- key={`${text(action.id, "walk")}-${group.id}`}
|
|
|
- />
|
|
|
- ))}
|
|
|
+ <div className="walk-result-video-grid">
|
|
|
+ {targetVideos.map((video, videoIndex) => (
|
|
|
+ <WalkResultVideoCard
|
|
|
+ video={video}
|
|
|
+ runId={runId}
|
|
|
+ tree={tree}
|
|
|
+ level={level}
|
|
|
+ seen={seen}
|
|
|
+ child={childByVideoId.get(text(video.id, ""))}
|
|
|
+ onOpenScore={onOpenScore}
|
|
|
+ key={text(video.id, `${text(video.title, "video")}-${videoIndex}`)}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
+ ) : !isSuccess ? (
|
|
|
+ <div className="walk-stop-note">{stoppedSentence(action)}</div>
|
|
|
) : null}
|
|
|
</div>
|
|
|
</article>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-function FlowNode({ label, value, href, tone = "plain" }: { label: string; value: string; href?: string; tone?: string }) {
|
|
|
- const content = (
|
|
|
- <>
|
|
|
- <span>{label}</span>
|
|
|
- <strong>{value}</strong>
|
|
|
- </>
|
|
|
- );
|
|
|
- if (href) {
|
|
|
- return <Link className={`walk-flow-node ${tone}`} href={href}>{content}</Link>;
|
|
|
- }
|
|
|
- return <div className={`walk-flow-node ${tone}`}>{content}</div>;
|
|
|
-}
|
|
|
+function WalkResultVideoCard({
|
|
|
+ video,
|
|
|
+ runId,
|
|
|
+ tree,
|
|
|
+ level,
|
|
|
+ seen,
|
|
|
+ child,
|
|
|
+ onOpenScore
|
|
|
+}: {
|
|
|
+ video: RawRecord;
|
|
|
+ runId: string;
|
|
|
+ tree: WalkTree;
|
|
|
+ level: number;
|
|
|
+ seen: Set<string>;
|
|
|
+ child?: WalkGroup;
|
|
|
+ onOpenScore: (drawer: BusinessDrawerState) => void;
|
|
|
+}) {
|
|
|
+ const decision = asRecord(video.decision);
|
|
|
+ const label = text(decision.label, "未判断");
|
|
|
+ const scores = videoScoreItems(video);
|
|
|
+ const titleParts = splitTitleAndTags(text(video.title, "无标题视频"));
|
|
|
|
|
|
-function FlowArrow() {
|
|
|
return (
|
|
|
- <span className="walk-flow-arrow" aria-hidden="true">
|
|
|
- <ArrowRight size={15} />
|
|
|
- </span>
|
|
|
+ <article className={`walk-result-video-card ${decisionTone(label)} ${child ? "has-child" : ""}`}>
|
|
|
+ <div className="walk-result-video-top">
|
|
|
+ <span className={`walk-result-status ${decisionTone(label)}`}>{label}</span>
|
|
|
+ {child ? <span className="walk-result-continue">继续游走</span> : <span className="walk-result-stop">{stopLabel(label)}</span>}
|
|
|
+ </div>
|
|
|
+ <Link className="walk-result-video-title" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(text(video.id, ""))}`}>
|
|
|
+ {titleParts.title}
|
|
|
+ </Link>
|
|
|
+ {titleParts.tags.length ? (
|
|
|
+ <div className="walk-source-tags compact-tags">
|
|
|
+ {titleParts.tags.slice(0, 4).map((tag) => <span key={tag}>{tag}</span>)}
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ <div className="walk-result-video-meta">{text(video.author, "未知作者")}</div>
|
|
|
+ {scores.length ? (
|
|
|
+ <div className="walk-score-chips">
|
|
|
+ {scores.slice(0, 3).map((item) => (
|
|
|
+ <span key={item.label}>{shortScoreLabel(item.label)} {item.score}</span>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ <VideoLinks video={video} runId={runId} compact />
|
|
|
+
|
|
|
+ {child ? (
|
|
|
+ <div className="walk-card-children">
|
|
|
+ <WalkGroupCard
|
|
|
+ group={child}
|
|
|
+ index={0}
|
|
|
+ runId={runId}
|
|
|
+ tree={tree}
|
|
|
+ level={level + 1}
|
|
|
+ seen={seen}
|
|
|
+ onOpenScore={onOpenScore}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ </article>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -396,9 +431,34 @@ function scoreSummary(value: unknown): string {
|
|
|
return visible.slice(0, 3).map((item) => `${shortScoreLabel(text(item.label, "评分"))} ${text(item.score_label)}`).join(" / ");
|
|
|
}
|
|
|
|
|
|
+function actionScoreItems(action: RawRecord): { label: string; score: string }[] {
|
|
|
+ const items = Array.isArray(action.score_items) ? action.score_items.filter(isRecord) : [];
|
|
|
+ return items
|
|
|
+ .map((item) => ({ label: text(item.label, "评分"), score: text(item.score_label, "") }))
|
|
|
+ .filter((item) => item.score);
|
|
|
+}
|
|
|
+
|
|
|
+function videoScoreItems(video: RawRecord): { label: string; score: string }[] {
|
|
|
+ const decision = asRecord(video.decision);
|
|
|
+ const items = Array.isArray(decision.score_items) ? decision.score_items.filter(isRecord) : [];
|
|
|
+ return items
|
|
|
+ .map((item) => ({ label: text(item.label, "评分"), score: text(item.score_label, "") }))
|
|
|
+ .filter((item) => item.score);
|
|
|
+}
|
|
|
+
|
|
|
function gateText(action: RawRecord, isSuccess: boolean): string {
|
|
|
- const score = scoreSummary(action.score_items);
|
|
|
- if (isSuccess) return score ? `通过:${firstScore(score)}` : "通过";
|
|
|
+ const score = firstScore(scoreSummary(action.score_items));
|
|
|
+ if (isSuccess) {
|
|
|
+ const edgeId = text(action.edge_id, "");
|
|
|
+ const label = edgeId === "hashtag_to_query" || edgeId === "tag_query"
|
|
|
+ ? "标签评分通过"
|
|
|
+ : edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author"
|
|
|
+ ? "作者评分通过"
|
|
|
+ : edgeId === "query_next_page"
|
|
|
+ ? "翻页通过"
|
|
|
+ : "筛选通过";
|
|
|
+ return score ? `${label}:${score}` : label;
|
|
|
+ }
|
|
|
const reason = cleanBusinessText(text(action.reason_label, ""));
|
|
|
if (reason.includes("游走次数上限")) return "未执行:达到游走次数上限";
|
|
|
if (reason.includes("复看")) return "未执行:待复看,需人工确认";
|
|
|
@@ -406,10 +466,51 @@ function gateText(action: RawRecord, isSuccess: boolean): string {
|
|
|
return `未执行:${reason || "没有继续扩展"}`;
|
|
|
}
|
|
|
|
|
|
+function scoreDrawer(action: RawRecord, routeName: string, isSuccess: boolean): BusinessDrawerState {
|
|
|
+ const items = actionScoreItems(action);
|
|
|
+ const total = items.find((item) => item.label === "总分")?.score || "";
|
|
|
+ const query = items.find((item) => item.label === "是否契合需求")?.score || "";
|
|
|
+ const platform = items.find((item) => item.label === "平台表现")?.score || "";
|
|
|
+ const reason = cleanBusinessText(text(action.reason_label, ""));
|
|
|
+ return {
|
|
|
+ title: `${routeName}筛选口径`,
|
|
|
+ sections: [
|
|
|
+ {
|
|
|
+ label: "这一步为什么能继续",
|
|
|
+ value: isSuccess
|
|
|
+ ? `${routeName}已通过筛选${total ? `,总分 ${total}` : ""}。系统认为这条路线值得继续带回视频。`
|
|
|
+ : `这一步没有继续执行。${reason ? `原因:${reason}` : "原因:没有达到继续扩展要求。"}`,
|
|
|
+ tone: isSuccess ? "good" : "warn"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: "评分怎么看",
|
|
|
+ items: [
|
|
|
+ total ? `总分:综合判断这条路线值不值得继续。当前 ${total}。` : "总分:本次没有记录总分。",
|
|
|
+ query ? `是否契合需求:看内容方向和本次需求是否贴合。当前 ${query}。` : "是否契合需求:本次没有记录该分。",
|
|
|
+ platform ? `平台表现:看点赞、评论、转发等平台反馈。当前 ${platform}。` : "平台表现:本次没有记录该分。"
|
|
|
+ ],
|
|
|
+ tone: "info"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: "对流程的影响",
|
|
|
+ value: isSuccess ? "通过后会生成新搜索词或翻页,并把带回的视频继续送去判断。" : "未通过时,这条路线停止,不再继续带回新视频。",
|
|
|
+ tone: "neutral"
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
function firstScore(value: string): string {
|
|
|
return value.split("/")[0]?.trim() || value;
|
|
|
}
|
|
|
|
|
|
+function stopLabel(label: string): string {
|
|
|
+ if (label === "入池") return "本卡未继续";
|
|
|
+ if (label === "待复看") return "待复看停止";
|
|
|
+ if (label === "淘汰") return "淘汰停止";
|
|
|
+ return "未继续";
|
|
|
+}
|
|
|
+
|
|
|
function terminalResultText(action: RawRecord): string {
|
|
|
const edgeId = text(action.edge_id, "");
|
|
|
if (edgeId === "decision_to_asset") return "入池";
|
|
|
@@ -429,51 +530,35 @@ function routeLabel(edgeId: string): string {
|
|
|
return "继续游走";
|
|
|
}
|
|
|
|
|
|
-function routeTriggerNode(action: RawRecord): { label: string; value: string } {
|
|
|
+function routeSubject(action: RawRecord): string {
|
|
|
const edgeId = text(action.edge_id, "");
|
|
|
const raw = cleanBusinessText(text(action.trigger_label, ""));
|
|
|
if (edgeId === "hashtag_to_query" || edgeId === "tag_query") {
|
|
|
const value = raw.replace(/^候选标签:/, "").replace(/^标签搜索词:/, "#").replace(/^标签:/, "");
|
|
|
- return { label: raw.startsWith("候选标签") ? "候选标签" : "视频标签", value: value || "标签待确认" };
|
|
|
+ return value || "标签待确认";
|
|
|
}
|
|
|
if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") {
|
|
|
- return { label: "作者", value: raw.replace(/^作者:/, "") || "作者待确认" };
|
|
|
+ return raw.replace(/^作者:/, "") || "作者待确认";
|
|
|
}
|
|
|
if (edgeId === "query_next_page") {
|
|
|
- return { label: "原搜索", value: raw.replace(/^翻下一页:/, "") || "下一页" };
|
|
|
- }
|
|
|
- return { label: "触发", value: raw || "当前内容" };
|
|
|
-}
|
|
|
-
|
|
|
-function routeResultNode(
|
|
|
- action: RawRecord,
|
|
|
- targetQuery: RawRecord,
|
|
|
- targetVideos: RawRecord[],
|
|
|
- isSuccess: boolean
|
|
|
-): { label: string; value: string; href?: string } {
|
|
|
- const edgeId = text(action.edge_id, "");
|
|
|
- if (!isSuccess) return { label: "停止", value: "没有继续带回新内容" };
|
|
|
- if (targetQuery.id) {
|
|
|
- const textValue = text(targetQuery.text, "搜索词待确认");
|
|
|
- const label = edgeId === "query_next_page" ? "下一页" : "新搜索词";
|
|
|
- return { label, value: textValue };
|
|
|
+ return cleanBusinessText(text(action.from_label, "当前搜索词")).replace(/^从搜索词/, "").replace(/继续$/, "") || "当前搜索词";
|
|
|
}
|
|
|
- if (targetVideos.length) return { label: "带回", value: `${targetVideos.length} 条视频` };
|
|
|
- return { label: "完成", value: cleanBusinessText(text(action.result, "已执行")) };
|
|
|
+ return raw || "当前内容";
|
|
|
}
|
|
|
|
|
|
-function successSentence(action: RawRecord, targetQuery: RawRecord, targetVideos: RawRecord[]): string {
|
|
|
+function routeSummary(action: RawRecord, targetQuery: RawRecord, targetVideos: RawRecord[], isSuccess: boolean): string {
|
|
|
+ if (!isSuccess) return stoppedSentence(action);
|
|
|
const edgeId = text(action.edge_id, "");
|
|
|
+ const subject = routeSubject(action);
|
|
|
if (edgeId === "query_next_page") {
|
|
|
- const from = cleanBusinessText(text(action.from_label, "当前搜索词")).replace(/继续$/, "");
|
|
|
- return `${from}翻到下一页,带回 ${targetVideos.length} 条视频。`;
|
|
|
+ return `从搜索词 ${subject} 翻到下一页,带回 ${targetVideos.length} 条视频。`;
|
|
|
}
|
|
|
if (edgeId === "hashtag_to_query" || edgeId === "tag_query") {
|
|
|
const queryText = text(targetQuery.text, "");
|
|
|
- return queryText ? `用标签生成搜索词《${queryText}》,带回 ${targetVideos.length} 条视频。` : `从标签继续搜,带回 ${targetVideos.length} 条视频。`;
|
|
|
+ return queryText ? `标签 ${subject} 通过筛选,生成搜索词《${queryText}》,带回 ${targetVideos.length} 条视频。` : `标签 ${subject} 通过筛选,带回 ${targetVideos.length} 条视频。`;
|
|
|
}
|
|
|
if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") {
|
|
|
- return `查看作者更多作品,带回 ${targetVideos.length} 条视频。`;
|
|
|
+ return `作者 ${subject} 通过筛选,继续查看作品,带回 ${targetVideos.length} 条视频。`;
|
|
|
}
|
|
|
return cleanBusinessText(text(action.result, "已执行"));
|
|
|
}
|