|
|
@@ -1,40 +1,41 @@
|
|
|
"use client";
|
|
|
|
|
|
import Link from "next/link";
|
|
|
-import { ExternalLink, Hash, Info, ListVideo, Search, UserSearch } from "lucide-react";
|
|
|
+import { ChevronDown, ChevronRight, ExternalLink, Hash, RotateCw, 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 { TechnicalRetryDetails } from "@/components/TechnicalRetryDetails";
|
|
|
import { getFlowLedgerWalk } from "@/lib/api/client";
|
|
|
import type { FlowLedgerWalkResponse, RawRecord } from "@/lib/api/types";
|
|
|
-import { videoFromApi } from "@/lib/flow-ledger/build";
|
|
|
-import { methodLabel } from "@/lib/flow-ledger/business";
|
|
|
-import { asRecord, text } from "@/lib/flow-ledger/format";
|
|
|
-
|
|
|
-type WalkGroup = {
|
|
|
+import { methodLabel, type BusinessTone } from "@/lib/flow-ledger/business";
|
|
|
+import { asArray, asRecord, text } from "@/lib/flow-ledger/format";
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// tree model
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+type RouteNode = { key: string; action: RawRecord; videos: VideoNode[] };
|
|
|
+type VideoNode = {
|
|
|
+ key: string;
|
|
|
id: string;
|
|
|
- kind: "video" | "query";
|
|
|
- video?: RawRecord;
|
|
|
+ video: RawRecord;
|
|
|
title: string;
|
|
|
author: string;
|
|
|
decisionLabel: string;
|
|
|
- terminalLabel: string;
|
|
|
- actions: RawRecord[];
|
|
|
+ decisionAction: string;
|
|
|
+ score: string;
|
|
|
+ tags: string[];
|
|
|
+ routes: RouteNode[];
|
|
|
};
|
|
|
|
|
|
-type WalkTree = {
|
|
|
- roots: WalkGroup[];
|
|
|
- childrenByActionId: Map<string, WalkGroup[]>;
|
|
|
- totalGroups: number;
|
|
|
-};
|
|
|
+type Forest = { rootVideos: VideoNode[]; queryRoutes: RouteNode[] };
|
|
|
|
|
|
export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: string }) {
|
|
|
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 [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
|
|
|
|
const load = useCallback(async () => {
|
|
|
setLoading(true);
|
|
|
@@ -54,556 +55,503 @@ export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: stri
|
|
|
}, [load]);
|
|
|
|
|
|
const query = asRecord(data?.query);
|
|
|
- const actions = useMemo(() => [...(data?.actions || [])].sort((a, b) => actionDepth(a) - actionDepth(b)), [data?.actions]);
|
|
|
- const tree = useMemo(() => buildWalkTree(actions), [actions]);
|
|
|
+ const actions = useMemo(() => (data?.actions || []).filter(isRecord), [data?.actions]);
|
|
|
+ const forest = useMemo(() => buildForest(actions), [actions]);
|
|
|
+ const keyBuckets = useMemo(() => collectKeys(forest), [forest]);
|
|
|
+
|
|
|
+ // default: show every video with its routes (the flow), brought-back videos collapsed.
|
|
|
+ useEffect(() => {
|
|
|
+ setExpanded(new Set(keyBuckets.videoKeys));
|
|
|
+ }, [keyBuckets]);
|
|
|
+
|
|
|
+ const toggle = useCallback((key: string) => {
|
|
|
+ setExpanded((prev) => {
|
|
|
+ const next = new Set(prev);
|
|
|
+ if (next.has(key)) next.delete(key);
|
|
|
+ else next.add(key);
|
|
|
+ return next;
|
|
|
+ });
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const setDepth = useCallback(
|
|
|
+ (mode: "collapse" | "routes" | "all") => {
|
|
|
+ if (mode === "collapse") setExpanded(new Set());
|
|
|
+ else if (mode === "routes") setExpanded(new Set(keyBuckets.videoKeys));
|
|
|
+ else setExpanded(new Set(keyBuckets.allKeys));
|
|
|
+ },
|
|
|
+ [keyBuckets]
|
|
|
+ );
|
|
|
+
|
|
|
+ const overview = useMemo(() => buildOverview(data?.summary, forest), [data?.summary, forest]);
|
|
|
+ const hasTree = forest.rootVideos.length > 0 || forest.queryRoutes.length > 0;
|
|
|
|
|
|
return (
|
|
|
- <AppShell title="继续扩展过程" subtitle={query.text ? `搜索词:${text(query.text)}` : "查看系统有没有继续向外找内容"} runId={runId} showBack onRefresh={load}>
|
|
|
+ <AppShell
|
|
|
+ title="游走路线图"
|
|
|
+ subtitle={query.text ? `搜索词:${text(query.text)}` : "看系统从这个搜索词向外找了哪些内容"}
|
|
|
+ runId={runId}
|
|
|
+ showBack
|
|
|
+ onRefresh={load}
|
|
|
+ >
|
|
|
{loading ? <LoadingState /> : null}
|
|
|
{error ? <ErrorState message={error} /> : null}
|
|
|
- {!loading && !error && !actions.length ? <EmptyState label="这个搜索词本轮没有继续扩展" /> : null}
|
|
|
- {data ? (
|
|
|
- <>
|
|
|
- <details className="declarations" open>
|
|
|
- <summary>
|
|
|
- <span className="kw">游走</span> <b>{text(query.text, "搜索词待确认")}</b>
|
|
|
- <span className="decl-purpose">{methodLabel(text(query.method, ""))}</span>
|
|
|
- <span className="tag-mini">{tree.totalGroups ? `${tree.totalGroups} 个起点` : "没有继续扩展"}</span>
|
|
|
- </summary>
|
|
|
- <div className="decl-body">
|
|
|
- <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>
|
|
|
+ {!loading && !error && !hasTree ? <EmptyState label="这个搜索词本轮没有继续向外扩展" /> : null}
|
|
|
|
|
|
- <section className="walk-ledger-board">
|
|
|
- <div className="walk-ledger-root">
|
|
|
- <span className="chip dark">当前搜索词</span>
|
|
|
+ {data && hasTree ? (
|
|
|
+ <section className="wt-page">
|
|
|
+ <header className="wt-overview">
|
|
|
+ <div className="wt-overview-head">
|
|
|
+ <span className="wt-root-chip">搜索词</span>
|
|
|
<strong>{text(query.text, "搜索词待确认")}</strong>
|
|
|
<small>{methodLabel(text(query.method, ""))}</small>
|
|
|
</div>
|
|
|
- <div className="walk-ledger-groups">
|
|
|
- {tree.roots.map((group, index) => (
|
|
|
- <WalkGroupCard group={group} index={index} runId={runId} tree={tree} level={0} onOpenScore={setDrawer} key={group.id} />
|
|
|
+ <div className="wt-overview-stats">
|
|
|
+ {overview.map((stat) => (
|
|
|
+ <span className={`wt-stat ${stat.tone || ""}`} key={stat.label}>
|
|
|
+ <b>{stat.value}</b>
|
|
|
+ {stat.label}
|
|
|
+ </span>
|
|
|
))}
|
|
|
</div>
|
|
|
- </section>
|
|
|
+ <div className="wt-controls">
|
|
|
+ <span className="wt-controls-label">展开</span>
|
|
|
+ <button type="button" onClick={() => setDepth("collapse")}>只看起点</button>
|
|
|
+ <button type="button" onClick={() => setDepth("routes")}>看到路线</button>
|
|
|
+ <button type="button" onClick={() => setDepth("all")}>全部展开</button>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <div className="wt-tree" role="tree">
|
|
|
+ {forest.rootVideos.map((node, index) => (
|
|
|
+ <VideoRow
|
|
|
+ node={node}
|
|
|
+ depth={0}
|
|
|
+ index={index}
|
|
|
+ runId={runId}
|
|
|
+ expanded={expanded}
|
|
|
+ onToggle={toggle}
|
|
|
+ onOpenDrawer={setDrawer}
|
|
|
+ key={node.key}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ {forest.queryRoutes.map((node) => (
|
|
|
+ <RouteRow
|
|
|
+ node={node}
|
|
|
+ depth={0}
|
|
|
+ runId={runId}
|
|
|
+ expanded={expanded}
|
|
|
+ onToggle={toggle}
|
|
|
+ onOpenDrawer={setDrawer}
|
|
|
+ key={node.key}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
<BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
|
|
|
- </>
|
|
|
+ </section>
|
|
|
) : null}
|
|
|
</AppShell>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-function Summary({ label, value }: { label: string; value: string }) {
|
|
|
- return (
|
|
|
- <div className="decl-section">
|
|
|
- <div className="decl-label">{label}</div>
|
|
|
- <div className="decl-row">{value}</div>
|
|
|
- </div>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-function WalkGroupCard({
|
|
|
- group,
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// rows
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+function VideoRow({
|
|
|
+ node,
|
|
|
+ depth,
|
|
|
index,
|
|
|
runId,
|
|
|
- tree,
|
|
|
- level,
|
|
|
- onOpenScore,
|
|
|
- seen = new Set<string>()
|
|
|
+ expanded,
|
|
|
+ onToggle,
|
|
|
+ onOpenDrawer
|
|
|
}: {
|
|
|
- group: WalkGroup;
|
|
|
- index: number;
|
|
|
+ node: VideoNode;
|
|
|
+ depth: number;
|
|
|
+ index?: number;
|
|
|
runId: string;
|
|
|
- tree: WalkTree;
|
|
|
- level: number;
|
|
|
- onOpenScore: (drawer: BusinessDrawerState) => void;
|
|
|
- seen?: Set<string>;
|
|
|
+ expanded: Set<string>;
|
|
|
+ onToggle: (key: string) => void;
|
|
|
+ onOpenDrawer: (drawer: BusinessDrawerState) => void;
|
|
|
}) {
|
|
|
- const routeActions = group.actions.filter((action) => !isTerminalEdge(text(action.edge_id, "")));
|
|
|
- const titleParts = splitTitleAndTags(group.title);
|
|
|
- const score = group.video ? scoreSummary(asRecord(group.video.decision).score_items) : "";
|
|
|
- const nextSeen = new Set(seen).add(group.id);
|
|
|
+ const open = expanded.has(node.key);
|
|
|
+ const hasRoutes = node.routes.length > 0;
|
|
|
+ const walked = node.routes.filter((r) => isWalked(r.action)).length;
|
|
|
+ const tone = decisionTone(node.decisionLabel);
|
|
|
|
|
|
return (
|
|
|
- <article className={`walk-story-block level-${Math.min(level, 3)}`} id={`source-${encodeURIComponent(group.id)}`}>
|
|
|
- <header className="walk-story-head">
|
|
|
- <span className="walk-source-kicker">{sourceKicker(group, index, level)}</span>
|
|
|
- <div className="walk-story-title">
|
|
|
- <h2>{titleParts.title}</h2>
|
|
|
- {titleParts.tags.length ? (
|
|
|
- <div className="walk-source-tags">
|
|
|
- {titleParts.tags.map((tag) => <span key={tag}>{tag}</span>)}
|
|
|
- </div>
|
|
|
- ) : null}
|
|
|
- </div>
|
|
|
- <VideoLinks video={group.video} runId={runId} />
|
|
|
- </header>
|
|
|
-
|
|
|
- <div className="walk-source-facts">
|
|
|
- {group.kind === "video" ? <Fact label="作者" value={group.author} /> : <Fact label="动作" value="搜索词翻页" tone="info" />}
|
|
|
- {group.kind === "video" ? <Fact label="判断" value={group.decisionLabel} tone={decisionTone(group.decisionLabel)} /> : <Fact label="起点" value={querySourceName(group.title)} />}
|
|
|
- {score ? <Fact label="评分" value={score} /> : null}
|
|
|
- {group.terminalLabel ? <Fact label="处理" value={group.terminalLabel} /> : null}
|
|
|
+ <div className="wt-branch" role="treeitem" aria-expanded={hasRoutes ? open : undefined}>
|
|
|
+ <div className={`wt-row video ${tone}`}>
|
|
|
+ <Toggle open={open} disabled={!hasRoutes} onClick={() => onToggle(node.key)} />
|
|
|
+ <button type="button" className="wt-row-main" onClick={() => onOpenDrawer(videoDrawer(node))}>
|
|
|
+ <span className={`wt-dot ${tone}`} />
|
|
|
+ <span className="wt-kicker">{depth === 0 ? `起点 ${(index ?? 0) + 1}` : "带回"}</span>
|
|
|
+ <span className="wt-title">{node.title}</span>
|
|
|
+ <span className="wt-author">{node.author}</span>
|
|
|
+ {node.score ? <span className="wt-score">{node.score}</span> : null}
|
|
|
+ <span className={`wt-status ${tone}`}>{node.decisionLabel}</span>
|
|
|
+ </button>
|
|
|
+ <span className="wt-row-right">
|
|
|
+ {hasRoutes ? (
|
|
|
+ <span className={`wt-badge ${walked ? "go" : "stop"}`}>
|
|
|
+ {walked ? `${walked}/${node.routes.length} 路线已游走` : `${node.routes.length} 路线未游走`}
|
|
|
+ </span>
|
|
|
+ ) : (
|
|
|
+ <span className="wt-badge muted">无路线</span>
|
|
|
+ )}
|
|
|
+ <RowLinks video={node.video} runId={runId} />
|
|
|
+ </span>
|
|
|
</div>
|
|
|
-
|
|
|
- <section className="walk-story-routes" aria-label={`${group.title} 的游走路线`}>
|
|
|
- <div className="walk-story-routes-label">{group.kind === "video" ? "从这条视频继续往外找" : "从这个搜索词翻下一页"}</div>
|
|
|
- {routeActions.length ? routeActions.map((action, actionIndex) => (
|
|
|
- <WalkRouteCard
|
|
|
- action={action}
|
|
|
- runId={runId}
|
|
|
- sourceId={group.id}
|
|
|
- tree={tree}
|
|
|
- level={level}
|
|
|
- onOpenScore={onOpenScore}
|
|
|
- seen={nextSeen}
|
|
|
- key={`${text(action.id, "walk")}-${actionIndex}`}
|
|
|
- />
|
|
|
- )) : <div className="walk-no-route">{group.kind === "video" ? "这条视频没有继续产生标签、作者或翻页路线。" : "这个搜索词没有继续翻页或产生新路线。"}</div>}
|
|
|
- </section>
|
|
|
- </article>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-function Fact({ label, value, tone = "plain" }: { label: string; value: string; tone?: string }) {
|
|
|
- return (
|
|
|
- <div className={`walk-fact ${tone}`}>
|
|
|
- <span>{label}</span>
|
|
|
- <strong>{value || "待确认"}</strong>
|
|
|
+ {open && hasRoutes ? (
|
|
|
+ <div className="wt-children">
|
|
|
+ {node.routes.map((route) => (
|
|
|
+ <RouteRow
|
|
|
+ node={route}
|
|
|
+ depth={depth + 1}
|
|
|
+ runId={runId}
|
|
|
+ expanded={expanded}
|
|
|
+ onToggle={onToggle}
|
|
|
+ onOpenDrawer={onOpenDrawer}
|
|
|
+ key={route.key}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-function WalkRouteCard({
|
|
|
- action,
|
|
|
+function RouteRow({
|
|
|
+ node,
|
|
|
+ depth,
|
|
|
runId,
|
|
|
- sourceId,
|
|
|
- tree,
|
|
|
- level,
|
|
|
- onOpenScore,
|
|
|
- seen
|
|
|
+ expanded,
|
|
|
+ onToggle,
|
|
|
+ onOpenDrawer
|
|
|
}: {
|
|
|
- action: RawRecord;
|
|
|
+ node: RouteNode;
|
|
|
+ depth: number;
|
|
|
runId: string;
|
|
|
- sourceId: string;
|
|
|
- tree: WalkTree;
|
|
|
- level: number;
|
|
|
- onOpenScore: (drawer: BusinessDrawerState) => void;
|
|
|
- seen: Set<string>;
|
|
|
+ expanded: Set<string>;
|
|
|
+ onToggle: (key: string) => void;
|
|
|
+ onOpenDrawer: (drawer: BusinessDrawerState) => void;
|
|
|
}) {
|
|
|
- const edgeId = text(action.edge_id, "");
|
|
|
- const targetQuery = asRecord(action.target_query);
|
|
|
- const targetVideos = Array.isArray(action.target_videos) ? action.target_videos.filter(isRecord) : [];
|
|
|
- const status = text(action.status, "");
|
|
|
- const isSuccess = status === "success";
|
|
|
- const routeName = routeLabel(edgeId);
|
|
|
- const gate = gateText(action, isSuccess);
|
|
|
- const childGroups = (tree.childrenByActionId.get(actionKey(action)) || []).filter((group) => !seen.has(group.id));
|
|
|
- const childByVideoId = new Map(childGroups.map((group) => [group.id, group]));
|
|
|
+ const open = expanded.has(node.key);
|
|
|
+ const walked = isWalked(node.action);
|
|
|
+ const hasVideos = node.videos.length > 0;
|
|
|
+ const edge = text(node.action.edge_id, "");
|
|
|
+ const targetQuery = asRecord(node.action.target_query);
|
|
|
|
|
|
return (
|
|
|
- <article className={`walk-tree-route ${edgeTone(edgeId)} ${isSuccess ? "success" : "stopped"}`} id={`route-${encodeURIComponent(text(action.id, routeName))}`}>
|
|
|
- <div className="walk-tree-route-dot" />
|
|
|
- <div className="walk-tree-route-body">
|
|
|
- <div className="walk-route-head">
|
|
|
- <span className="walk-edge-icon">{edgeIcon(edgeId)}</span>
|
|
|
- <div>
|
|
|
- <strong>{routeName}</strong>
|
|
|
- <p>{routeSummary(action, targetQuery, targetVideos, isSuccess)}</p>
|
|
|
- </div>
|
|
|
- <span className={`chip ${isSuccess ? "green" : "yellow"}`}>{isSuccess ? "已游走" : "未游走"}</span>
|
|
|
- </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>
|
|
|
+ <div className="wt-branch" role="treeitem" aria-expanded={hasVideos ? open : undefined}>
|
|
|
+ <div className={`wt-row route ${edgeTone(edge)} ${walked ? "go" : "stop"}`}>
|
|
|
+ <Toggle open={open} disabled={!hasVideos} onClick={() => onToggle(node.key)} />
|
|
|
+ <button type="button" className="wt-row-main" onClick={() => onOpenDrawer(routeDrawer(node))}>
|
|
|
+ <span className="wt-edge-icon">{edgeIcon(edge)}</span>
|
|
|
+ <span className="wt-title route">{routeObject(node.action)}</span>
|
|
|
+ {targetQuery.text && walked ? <span className="wt-newquery">→《{text(targetQuery.text)}》</span> : null}
|
|
|
+ <span className={`wt-walk ${walked ? "go" : "stop"}`}>{walked ? "已游走" : "未游走"}</span>
|
|
|
+ {!walked ? <span className="wt-reason">{shortReason(node.action)}</span> : null}
|
|
|
+ </button>
|
|
|
+ <span className="wt-row-right">
|
|
|
+ {hasVideos ? <span className="wt-badge go">带回 {node.videos.length}</span> : null}
|
|
|
{targetQuery.id ? (
|
|
|
- <Link className="walk-jump-button" href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(text(targetQuery.id, ""))}/videos`}>
|
|
|
- 查看带回视频
|
|
|
+ <Link
|
|
|
+ className="wt-link"
|
|
|
+ href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(text(targetQuery.id))}/videos`}
|
|
|
+ onClick={(e) => e.stopPropagation()}
|
|
|
+ >
|
|
|
+ 带回视频
|
|
|
</Link>
|
|
|
) : null}
|
|
|
- <a className="walk-jump-button" href={`#source-${encodeURIComponent(sourceId)}`}>回到起点</a>
|
|
|
- </div>
|
|
|
-
|
|
|
- {targetVideos.length ? (
|
|
|
- <div className="walk-result-videos">
|
|
|
- <div className="walk-result-title">
|
|
|
- <ListVideo size={15} />
|
|
|
- <span>带回视频 {targetVideos.length} 条</span>
|
|
|
- </div>
|
|
|
- <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 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, "无标题视频"));
|
|
|
- const detail = text(decision.action, "") === "TECHNICAL_RETRY_REQUIRED"
|
|
|
- ? videoFromApi(video).technicalRetryDetail
|
|
|
- : null;
|
|
|
-
|
|
|
- return (
|
|
|
- <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>
|
|
|
- {detail ? <TechnicalRetryDetails detail={detail} compact /> : null}
|
|
|
- {child ? <span className="walk-result-continue">继续游走</span> : <span className="walk-result-stop">{stopLabel(label)}</span>}
|
|
|
+ </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>
|
|
|
+ {open && hasVideos ? (
|
|
|
+ <div className="wt-children">
|
|
|
+ {node.videos.map((video) => (
|
|
|
+ <VideoRow
|
|
|
+ node={video}
|
|
|
+ depth={depth + 1}
|
|
|
+ runId={runId}
|
|
|
+ expanded={expanded}
|
|
|
+ onToggle={onToggle}
|
|
|
+ onOpenDrawer={onOpenDrawer}
|
|
|
+ key={video.key}
|
|
|
+ />
|
|
|
))}
|
|
|
</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>
|
|
|
+ </div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-function buildWalkTree(actions: RawRecord[]): WalkTree {
|
|
|
- const groups = buildWalkGroups(actions);
|
|
|
- const byId = new Map(groups.map((group) => [group.id, group]));
|
|
|
- const childrenByActionId = new Map<string, WalkGroup[]>();
|
|
|
- const childIds = new Set<string>();
|
|
|
-
|
|
|
- groups.forEach((group) => {
|
|
|
- group.actions.filter((action) => !isTerminalEdge(text(action.edge_id, ""))).forEach((action) => {
|
|
|
- const targetVideos = Array.isArray(action.target_videos) ? action.target_videos.filter(isRecord) : [];
|
|
|
- const children = targetVideos
|
|
|
- .map((video) => byId.get(text(video.id, "")))
|
|
|
- .filter((child): child is WalkGroup => child !== undefined && child.id !== group.id);
|
|
|
- if (!children.length) return;
|
|
|
- const uniqueChildren = [...new Map(children.map((child) => [child.id, child])).values()];
|
|
|
- childrenByActionId.set(actionKey(action), uniqueChildren);
|
|
|
- uniqueChildren.forEach((child) => childIds.add(child.id));
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- const roots = groups.filter((group) => !childIds.has(group.id));
|
|
|
- return {
|
|
|
- roots: roots.length ? roots : groups,
|
|
|
- childrenByActionId,
|
|
|
- totalGroups: groups.length
|
|
|
- };
|
|
|
-}
|
|
|
-
|
|
|
-function buildWalkGroups(actions: RawRecord[]): WalkGroup[] {
|
|
|
- const groups = new Map<string, WalkGroup>();
|
|
|
- actions.forEach((action, index) => {
|
|
|
- const video = asRecord(action.source_video);
|
|
|
- const terminal = isTerminalEdge(text(action.edge_id, ""));
|
|
|
- const id = text(video.id) || `search-${text(action.from_label, "root")}-${index}`;
|
|
|
- if (!groups.has(id)) {
|
|
|
- const decision = asRecord(video.decision);
|
|
|
- groups.set(id, {
|
|
|
- id,
|
|
|
- kind: video.id ? "video" : "query",
|
|
|
- video: video.id ? video : undefined,
|
|
|
- title: video.id ? text(video.title, "无标题视频") : queryGroupTitle(action),
|
|
|
- author: video.id ? text(video.author, "未知作者") : "搜索词翻页",
|
|
|
- decisionLabel: text(decision.label, "未判断"),
|
|
|
- terminalLabel: terminal ? terminalResultText(action) : "",
|
|
|
- actions: []
|
|
|
- });
|
|
|
- }
|
|
|
- const group = groups.get(id)!;
|
|
|
- if (terminal && !group.terminalLabel) group.terminalLabel = terminalResultText(action);
|
|
|
- group.actions.push(action);
|
|
|
- });
|
|
|
- return [...groups.values()];
|
|
|
-}
|
|
|
-
|
|
|
-function actionKey(action: RawRecord): string {
|
|
|
- return text(action.id || action.walk_action_id || `${text(action.edge_id, "walk")}-${text(action.from_label, "")}-${text(action.result, "")}`);
|
|
|
-}
|
|
|
-
|
|
|
-function queryGroupTitle(action: RawRecord): string {
|
|
|
- const from = cleanBusinessText(text(action.from_label, ""));
|
|
|
- if (from) return from.replace(/^从搜索词/, "搜索词").replace(/继续$/, "");
|
|
|
- return "当前搜索词";
|
|
|
-}
|
|
|
-
|
|
|
-function sourceKicker(group: WalkGroup, index: number, level: number): string {
|
|
|
- if (level) return `第 ${level + 1} 层起点`;
|
|
|
- return group.kind === "video" ? `起点视频 ${index + 1}` : "搜索词翻页";
|
|
|
-}
|
|
|
-
|
|
|
-function querySourceName(value: string): string {
|
|
|
- return value.replace(/^搜索词《/, "").replace(/》$/, "") || "搜索词待确认";
|
|
|
+function Toggle({ open, disabled, onClick }: { open: boolean; disabled: boolean; onClick: () => void }) {
|
|
|
+ if (disabled) return <span className="wt-toggle empty" />;
|
|
|
+ return (
|
|
|
+ <button type="button" className="wt-toggle" aria-label={open ? "收起" : "展开"} onClick={onClick}>
|
|
|
+ {open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
|
|
+ </button>
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
-function VideoLinks({ video, runId, compact = false }: { video?: RawRecord; runId: string; compact?: boolean }) {
|
|
|
- if (!video || !video.id) return null;
|
|
|
+function RowLinks({ video, runId, }: { video: RawRecord; runId: string }) {
|
|
|
+ const id = text(video.id, "");
|
|
|
+ if (!id) return null;
|
|
|
const platformUrl = text(video.platform_url, "");
|
|
|
+ const platformLabel = text(video.platform_label, "原视频");
|
|
|
return (
|
|
|
- <div className={compact ? "walk-video-links compact" : "walk-video-links"}>
|
|
|
- <Link href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(text(video.id, ""))}`}>
|
|
|
- 看详情
|
|
|
+ <span className="wt-links">
|
|
|
+ <Link className="wt-link" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(id)}`} onClick={(e) => e.stopPropagation()}>
|
|
|
+ 详情
|
|
|
</Link>
|
|
|
{platformUrl ? (
|
|
|
- <a href={platformUrl} target="_blank" rel="noreferrer">
|
|
|
- <ExternalLink size={12} />
|
|
|
- 抖音
|
|
|
+ <a className="wt-link" href={platformUrl} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>
|
|
|
+ <ExternalLink size={11} />
|
|
|
+ {platformLabel}
|
|
|
</a>
|
|
|
) : null}
|
|
|
- </div>
|
|
|
+ </span>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-function actionDepth(action: RawRecord): number {
|
|
|
- const value = Number(action.depth || 0);
|
|
|
- return Number.isFinite(value) ? value : 0;
|
|
|
-}
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// build
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+function buildForest(actions: RawRecord[]): Forest {
|
|
|
+ const routable = actions.filter((a) => !isTerminalEdge(text(a.edge_id, "")));
|
|
|
+ const routesBySource = new Map<string, RawRecord[]>();
|
|
|
+ const sourceIds = new Set<string>();
|
|
|
+ const targetIds = new Set<string>();
|
|
|
+ const queryRouteActions: RawRecord[] = [];
|
|
|
+
|
|
|
+ routable.forEach((action) => {
|
|
|
+ const sourceVideo = asRecord(action.source_video);
|
|
|
+ const sid = text(sourceVideo.id, "");
|
|
|
+ if (sid) {
|
|
|
+ sourceIds.add(sid);
|
|
|
+ const bucket = routesBySource.get(sid) || [];
|
|
|
+ bucket.push(action);
|
|
|
+ routesBySource.set(sid, bucket);
|
|
|
+ } else {
|
|
|
+ queryRouteActions.push(action);
|
|
|
+ }
|
|
|
+ asArray(action.target_videos)
|
|
|
+ .filter(isRecord)
|
|
|
+ .forEach((v) => {
|
|
|
+ const vid = text(v.id, "");
|
|
|
+ if (vid) targetIds.add(vid);
|
|
|
+ });
|
|
|
+ });
|
|
|
|
|
|
-function splitTitleAndTags(value: string): { title: string; tags: string[] } {
|
|
|
- const matches = value.match(/#[^\s#]+/g) || [];
|
|
|
- const title = value.replace(/#[^\s#]+/g, " ").replace(/\s+/g, " ").trim();
|
|
|
- return {
|
|
|
- title: title || value,
|
|
|
- tags: matches.slice(0, 6)
|
|
|
+ const sourceVideoById = new Map<string, RawRecord>();
|
|
|
+ routable.forEach((action) => {
|
|
|
+ const sv = asRecord(action.source_video);
|
|
|
+ const sid = text(sv.id, "");
|
|
|
+ if (sid && !sourceVideoById.has(sid)) sourceVideoById.set(sid, sv);
|
|
|
+ });
|
|
|
+
|
|
|
+ const buildRoute = (action: RawRecord, parentKey: string, seen: Set<string>): RouteNode => {
|
|
|
+ const key = `${parentKey}>r:${actionKey(action)}`;
|
|
|
+ const videos = asArray(action.target_videos)
|
|
|
+ .filter(isRecord)
|
|
|
+ .map((v) => buildVideo(v, key, routesBySource, seen))
|
|
|
+ .filter((v): v is VideoNode => v !== null);
|
|
|
+ return { key, action, videos };
|
|
|
};
|
|
|
-}
|
|
|
|
|
|
-function scoreSummary(value: unknown): string {
|
|
|
- const items = Array.isArray(value) ? value.filter(isRecord) : [];
|
|
|
- const visible = items.filter((item) => text(item.score_label));
|
|
|
- if (!visible.length) return "";
|
|
|
- return visible.slice(0, 3).map((item) => `${shortScoreLabel(text(item.label, "评分"))} ${text(item.score_label)}`).join(" / ");
|
|
|
-}
|
|
|
+ function buildVideo(video: RawRecord, parentKey: string, routes: Map<string, RawRecord[]>, seen: Set<string>): VideoNode | null {
|
|
|
+ const id = text(video.id, "");
|
|
|
+ const key = `${parentKey}>v:${id || Math.random().toString(36).slice(2)}`;
|
|
|
+ const decision = asRecord(video.decision);
|
|
|
+ const childRoutes = id && !seen.has(id) ? routes.get(id) || [] : [];
|
|
|
+ const nextSeen = new Set(seen);
|
|
|
+ if (id) nextSeen.add(id);
|
|
|
+ return {
|
|
|
+ key,
|
|
|
+ id,
|
|
|
+ video,
|
|
|
+ title: cleanTitle(text(video.title, "无标题视频")),
|
|
|
+ author: text(video.author, "未知作者"),
|
|
|
+ decisionLabel: text(decision.label, "未判断"),
|
|
|
+ decisionAction: text(decision.action, ""),
|
|
|
+ score: scoreText(decision),
|
|
|
+ tags: asArray(video.tags).map((t) => text(t, "")).filter(Boolean),
|
|
|
+ routes: childRoutes.map((a) => buildRoute(a, key, nextSeen))
|
|
|
+ };
|
|
|
+ }
|
|
|
|
|
|
-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);
|
|
|
-}
|
|
|
+ const rootVideos: VideoNode[] = [...sourceIds]
|
|
|
+ .filter((id) => !targetIds.has(id))
|
|
|
+ .map((id) => buildVideo(sourceVideoById.get(id) || { id }, "root", routesBySource, new Set<string>()))
|
|
|
+ .filter((v): v is VideoNode => v !== null);
|
|
|
|
|
|
-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);
|
|
|
-}
|
|
|
+ const queryRoutes = queryRouteActions.map((a) => buildRoute(a, "root", new Set<string>()));
|
|
|
|
|
|
-function gateText(action: RawRecord, isSuccess: boolean): string {
|
|
|
- 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 "原因:待复看,先不继续扩展";
|
|
|
- if (reason.includes("不适合继续向外扩展")) return `原因:${reason.replace(/^不适合继续向外扩展:?/, "") || "不适合继续向外扩展"},未继续扩展`;
|
|
|
- return `原因:${reason || "没有达到继续扩展要求"},未继续扩展`;
|
|
|
+ return { rootVideos, queryRoutes };
|
|
|
}
|
|
|
|
|
|
-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, ""));
|
|
|
+function collectKeys(forest: Forest): { videoKeys: Set<string>; routeKeys: Set<string>; allKeys: Set<string> } {
|
|
|
+ const videoKeys = new Set<string>();
|
|
|
+ const routeKeys = new Set<string>();
|
|
|
+ const walkVideo = (node: VideoNode) => {
|
|
|
+ if (node.routes.length) videoKeys.add(node.key);
|
|
|
+ node.routes.forEach(walkRoute);
|
|
|
+ };
|
|
|
+ const walkRoute = (node: RouteNode) => {
|
|
|
+ if (node.videos.length) routeKeys.add(node.key);
|
|
|
+ node.videos.forEach(walkVideo);
|
|
|
+ };
|
|
|
+ forest.rootVideos.forEach(walkVideo);
|
|
|
+ forest.queryRoutes.forEach(walkRoute);
|
|
|
+ return { videoKeys, routeKeys, allKeys: new Set([...videoKeys, ...routeKeys]) };
|
|
|
+}
|
|
|
+
|
|
|
+function buildOverview(summary: unknown, forest: Forest): { label: string; value: string; tone?: string }[] {
|
|
|
+ const s = asRecord(summary);
|
|
|
+ return [
|
|
|
+ { label: "起点视频", value: String(forest.rootVideos.length) },
|
|
|
+ { label: "已游走", value: String(Number(s.success_count || 0)), tone: "go" },
|
|
|
+ { label: "未游走", value: String(Number(s.skipped_count || 0)), tone: "stop" },
|
|
|
+ { label: "新增搜索词", value: String(Number(s.extension_query_count || 0)) }
|
|
|
+ ];
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// drawers
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+function videoDrawer(node: VideoNode): BusinessDrawerState {
|
|
|
+ const decision = asRecord(node.video.decision);
|
|
|
+ const scoreItems = asArray(decision.score_items).filter(isRecord);
|
|
|
+ const reason = text(decision.reason_label, "");
|
|
|
return {
|
|
|
- title: `${routeName}筛选口径`,
|
|
|
+ title: node.title,
|
|
|
sections: [
|
|
|
{
|
|
|
- label: "这一步为什么能继续",
|
|
|
- value: isSuccess
|
|
|
- ? `${routeName}已通过筛选${total ? `,总分 ${total}` : ""}。系统认为这条路线值得继续带回视频。`
|
|
|
- : `这一步没有继续游走。${reason ? `原因:${reason}` : "原因:没有达到继续扩展要求。"}`,
|
|
|
- tone: isSuccess ? "good" : "warn"
|
|
|
+ label: "这条视频的判断",
|
|
|
+ value: `${node.decisionLabel}${reason ? `:${reason}` : ""}`,
|
|
|
+ tone: drawerTone(node.decisionLabel)
|
|
|
},
|
|
|
+ scoreItems.length
|
|
|
+ ? {
|
|
|
+ label: "评分明细",
|
|
|
+ items: scoreItems.map((item) => `${text(item.label, "评分")}:${text(item.score_label, "未记录")}`),
|
|
|
+ tone: "info"
|
|
|
+ }
|
|
|
+ : { label: "评分明细", value: "本条没有记录评分。", tone: "info" },
|
|
|
+ node.tags.length ? { label: "视频标签", value: node.tags.join(" "), tone: "neutral" } : { label: "作者", value: node.author, tone: "neutral" }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function routeDrawer(node: RouteNode): BusinessDrawerState {
|
|
|
+ const action = node.action;
|
|
|
+ const walked = isWalked(action);
|
|
|
+ const detailLines = asArray(action.detail_lines).map((l) => text(l, "")).filter(Boolean);
|
|
|
+ return {
|
|
|
+ title: `${routeKindLabel(text(action.edge_id, ""))}`,
|
|
|
+ sections: [
|
|
|
{
|
|
|
- label: "评分怎么看",
|
|
|
- items: [
|
|
|
- total ? `总分:综合判断这条路线值不值得继续。当前 ${total}。` : "总分:本次没有记录总分。",
|
|
|
- query ? `是否契合需求:看内容方向和本次需求是否贴合。当前 ${query}。` : "是否契合需求:本次没有记录该分。",
|
|
|
- platform ? `平台表现:看点赞、评论、转发等平台反馈。当前 ${platform}。` : "平台表现:本次没有记录该分。"
|
|
|
- ],
|
|
|
- tone: "info"
|
|
|
+ label: walked ? "这一步为什么继续" : "这一步为什么停下",
|
|
|
+ value: walked
|
|
|
+ ? `${routeObject(action)} 通过筛选,继续带回视频。`
|
|
|
+ : `${routeObject(action)} 没有继续。原因:${shortReason(action)}。`,
|
|
|
+ tone: walked ? "good" : "warn"
|
|
|
},
|
|
|
- {
|
|
|
- label: "对流程的影响",
|
|
|
- value: isSuccess ? "通过后会生成新搜索词或翻页,并把带回的视频继续送去判断。" : "未通过时,这条路线停止,不再继续带回新视频。",
|
|
|
- tone: "neutral"
|
|
|
- }
|
|
|
+ detailLines.length ? { label: "执行明细", items: detailLines, tone: "info" } : { label: "执行明细", value: text(action.summary, "没有更多明细。"), tone: "info" },
|
|
|
+ { label: "对流程的影响", value: text(action.impact, walked ? "带回的视频会继续送去判断。" : "这条路线停止,不再带回新视频。"), tone: "neutral" }
|
|
|
]
|
|
|
};
|
|
|
}
|
|
|
|
|
|
-function firstScore(value: string): string {
|
|
|
- return value.split("/")[0]?.trim() || value;
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// helpers
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+function isRecord(value: unknown): value is RawRecord {
|
|
|
+ return typeof value === "object" && value !== null;
|
|
|
}
|
|
|
|
|
|
-function stopLabel(label: string): string {
|
|
|
- if (label === "入池") return "本卡未继续";
|
|
|
- if (label === "待复看") return "待复看停止";
|
|
|
- if (label === "淘汰") return "淘汰停止";
|
|
|
- return "未继续";
|
|
|
+function actionKey(action: RawRecord): string {
|
|
|
+ return text(action.id || action.walk_action_id || `${text(action.edge_id, "walk")}-${text(action.trigger_label, "")}`);
|
|
|
}
|
|
|
|
|
|
-function terminalResultText(action: RawRecord): string {
|
|
|
- const edgeId = text(action.edge_id, "");
|
|
|
- if (edgeId === "decision_to_asset") return "入池";
|
|
|
- if (edgeId === "budget_downgrade") return "待复看";
|
|
|
- if (edgeId === "path_stop") return "淘汰并停止";
|
|
|
- return cleanBusinessText(text(action.result, "已记录"));
|
|
|
+function isWalked(action: RawRecord): boolean {
|
|
|
+ return text(action.status, "") === "success";
|
|
|
}
|
|
|
|
|
|
function isTerminalEdge(edgeId: string): boolean {
|
|
|
return edgeId === "decision_to_asset" || edgeId === "budget_downgrade" || edgeId === "path_stop";
|
|
|
}
|
|
|
|
|
|
-function routeLabel(edgeId: string): string {
|
|
|
- if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "从视频标签继续游走";
|
|
|
- if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "从作者继续游走";
|
|
|
- if (edgeId === "query_next_page") return "翻页继续游走";
|
|
|
- return "继续游走";
|
|
|
+function cleanTitle(value: string): string {
|
|
|
+ return value.replace(/#[^\s#]+/g, " ").replace(/\s+/g, " ").trim() || value;
|
|
|
}
|
|
|
|
|
|
-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 value || "标签待确认";
|
|
|
- }
|
|
|
- if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") {
|
|
|
- return raw.replace(/^作者:/, "") || "作者待确认";
|
|
|
- }
|
|
|
- if (edgeId === "query_next_page") {
|
|
|
- return cleanBusinessText(text(action.from_label, "当前搜索词")).replace(/^从搜索词/, "").replace(/继续$/, "") || "当前搜索词";
|
|
|
- }
|
|
|
- return raw || "当前内容";
|
|
|
+function scoreText(decision: RawRecord): string {
|
|
|
+ const label = text(decision.score_label, "");
|
|
|
+ if (label && label !== "未记录") return label;
|
|
|
+ const score = Number(decision.score);
|
|
|
+ return Number.isFinite(score) && score > 0 ? `${Math.round(score)} 分` : "";
|
|
|
}
|
|
|
|
|
|
-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") {
|
|
|
- return `从搜索词 ${subject} 翻到下一页,带回 ${targetVideos.length} 条视频。`;
|
|
|
- }
|
|
|
- if (edgeId === "hashtag_to_query" || edgeId === "tag_query") {
|
|
|
- const queryText = text(targetQuery.text, "");
|
|
|
- return queryText ? `标签 ${subject} 通过筛选,生成搜索词《${queryText}》,带回 ${targetVideos.length} 条视频。` : `标签 ${subject} 通过筛选,带回 ${targetVideos.length} 条视频。`;
|
|
|
- }
|
|
|
- if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") {
|
|
|
- return `作者 ${subject} 通过筛选,继续查看作品,带回 ${targetVideos.length} 条视频。`;
|
|
|
- }
|
|
|
- return cleanBusinessText(text(action.result, "已执行"));
|
|
|
+function decisionTone(label: string): string {
|
|
|
+ if (label === "入池") return "good";
|
|
|
+ if (label === "待复看") return "warn";
|
|
|
+ if (label === "淘汰") return "bad";
|
|
|
+ if (label.includes("重试") || label.includes("补跑")) return "tech";
|
|
|
+ return "plain";
|
|
|
}
|
|
|
|
|
|
-function stoppedSentence(action: RawRecord): string {
|
|
|
- return gateText(action, false);
|
|
|
+function drawerTone(label: string): BusinessTone {
|
|
|
+ if (label === "入池") return "good";
|
|
|
+ if (label === "淘汰") return "bad";
|
|
|
+ return "warn";
|
|
|
}
|
|
|
|
|
|
-function shortScoreLabel(label: string): string {
|
|
|
- if (label === "是否契合需求") return "需求";
|
|
|
- if (label === "平台表现") return "平台";
|
|
|
- return label;
|
|
|
+function edgeTone(edgeId: string): string {
|
|
|
+ if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "tag";
|
|
|
+ if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "author";
|
|
|
+ if (edgeId === "query_next_page") return "page";
|
|
|
+ return "plain";
|
|
|
}
|
|
|
|
|
|
-function cleanBusinessText(value: string): string {
|
|
|
- if (!value || value === "系统原因待补充" || value === "这一步没有记录额外原因") return "";
|
|
|
- return value.replaceAll("“", "《").replaceAll("”", "》");
|
|
|
+function edgeIcon(edgeId: string) {
|
|
|
+ if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return <Hash size={14} />;
|
|
|
+ if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return <UserSearch size={14} />;
|
|
|
+ if (edgeId === "query_next_page") return <RotateCw size={14} />;
|
|
|
+ return <Search size={14} />;
|
|
|
}
|
|
|
|
|
|
-function decisionTone(value: string): string {
|
|
|
- if (value === "入池") return "good";
|
|
|
- if (value === "待复看") return "warn";
|
|
|
- if (value === "淘汰") return "bad";
|
|
|
- return "plain";
|
|
|
+function routeKindLabel(edgeId: string): string {
|
|
|
+ if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "从视频标签继续游走";
|
|
|
+ if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "从作者继续游走";
|
|
|
+ if (edgeId === "query_next_page") return "翻页继续找";
|
|
|
+ return "继续游走";
|
|
|
}
|
|
|
|
|
|
-function edgeTone(edgeId: string): string {
|
|
|
- if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "tag";
|
|
|
- if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "author";
|
|
|
- if (edgeId === "query_next_page") return "page";
|
|
|
- return "stop";
|
|
|
+function routeObject(action: RawRecord): string {
|
|
|
+ const edge = text(action.edge_id, "");
|
|
|
+ const raw = cleanBusinessText(text(action.trigger_label, ""));
|
|
|
+ if (edge === "query_next_page") return "翻下一页";
|
|
|
+ if (edge === "hashtag_to_query" || edge === "tag_query") {
|
|
|
+ const target = asRecord(action.target_query);
|
|
|
+ if (target.text && isWalked(action)) return `标签`;
|
|
|
+ const candidates = raw.replace(/^候选标签:/, "").split("/")[0]?.trim();
|
|
|
+ return candidates ? `标签 ${candidates}` : "标签";
|
|
|
+ }
|
|
|
+ if (edge === "author_to_works" || edge === "author_works" || edge === "video_to_author") {
|
|
|
+ return `作者 ${raw.replace(/^作者:/, "") || "待确认"}`;
|
|
|
+ }
|
|
|
+ return raw || "继续游走";
|
|
|
}
|
|
|
|
|
|
-function edgeIcon(edgeId: string) {
|
|
|
- if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return <Hash size={15} />;
|
|
|
- if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return <UserSearch size={15} />;
|
|
|
- return <Search size={15} />;
|
|
|
+function shortReason(action: RawRecord): string {
|
|
|
+ const reason = cleanBusinessText(text(action.reason_label, ""));
|
|
|
+ if (!reason) return "未达到继续扩展要求";
|
|
|
+ if (reason.includes("游走次数上限") || reason.includes("次数上限")) return "已达游走次数上限";
|
|
|
+ if (reason.includes("复看")) return "分数在复看区间";
|
|
|
+ return reason.replace(/^不适合继续向外扩展:?/, "");
|
|
|
}
|
|
|
|
|
|
-function isRecord(value: unknown): value is RawRecord {
|
|
|
- return typeof value === "object" && value !== null;
|
|
|
+function cleanBusinessText(value: string): string {
|
|
|
+ if (!value || value === "系统原因待补充" || value === "这一步没有记录额外原因") return "";
|
|
|
+ return value.replaceAll("“", "《").replaceAll("”", "》");
|
|
|
}
|