WalkTree.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. "use client";
  2. import Link from "next/link";
  3. import { ChevronDown, ChevronRight, ExternalLink, Hash, RotateCw, Search, UserSearch } from "lucide-react";
  4. import { useCallback, useEffect, useMemo, useState } from "react";
  5. import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
  6. import { EmptyState } from "@/components/StateBlocks";
  7. import type { RawRecord } from "@/lib/api/types";
  8. import type { BusinessTone } from "@/lib/flow-ledger/business";
  9. import { asArray, asRecord, maybeText, text } from "@/lib/flow-ledger/format";
  10. // ---------------------------------------------------------------------------
  11. // tree model
  12. // ---------------------------------------------------------------------------
  13. export type RouteNode = { key: string; action: RawRecord; videos: VideoNode[] };
  14. export type VideoNode = {
  15. key: string;
  16. id: string;
  17. searchQueryId: string;
  18. batchKind: string;
  19. pageNumber: number;
  20. rankInPage: number;
  21. video: RawRecord;
  22. title: string;
  23. author: string;
  24. decisionLabel: string;
  25. decisionAction: string;
  26. score: string;
  27. tags: string[];
  28. routes: RouteNode[];
  29. };
  30. export type Forest = { rootVideos: VideoNode[]; queryRoutes: RouteNode[] };
  31. type BatchGroup = { kind: string; label: string; gate: string; videos: VideoNode[] };
  32. type QueryGroup = { id: string; label: string; batches: BatchGroup[] };
  33. // ---------------------------------------------------------------------------
  34. // component
  35. // ---------------------------------------------------------------------------
  36. export function WalkTree({
  37. actions,
  38. runId,
  39. groupByQuery = false,
  40. queryLabels = {},
  41. emptyLabel = "本轮没有继续向外扩展"
  42. }: {
  43. actions: RawRecord[];
  44. runId: string;
  45. groupByQuery?: boolean;
  46. queryLabels?: Record<string, string>;
  47. emptyLabel?: string;
  48. }) {
  49. const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
  50. const [expanded, setExpanded] = useState<Set<string>>(new Set());
  51. const safeActions = useMemo(() => actions.filter(isRecord), [actions]);
  52. const forest = useMemo(() => buildForest(safeActions), [safeActions]);
  53. const keyBuckets = useMemo(() => collectKeys(forest), [forest]);
  54. // default: show every video with its routes (the flow), brought-back videos collapsed.
  55. useEffect(() => {
  56. setExpanded(new Set(keyBuckets.videoKeys));
  57. }, [keyBuckets]);
  58. const toggle = useCallback((key: string) => {
  59. setExpanded((prev) => {
  60. const next = new Set(prev);
  61. if (next.has(key)) next.delete(key);
  62. else next.add(key);
  63. return next;
  64. });
  65. }, []);
  66. const setDepth = useCallback(
  67. (mode: "collapse" | "routes" | "all") => {
  68. if (mode === "collapse") setExpanded(new Set());
  69. else if (mode === "routes") setExpanded(new Set(keyBuckets.videoKeys));
  70. else setExpanded(new Set(keyBuckets.allKeys));
  71. },
  72. [keyBuckets]
  73. );
  74. const hasTree = forest.rootVideos.length > 0 || forest.queryRoutes.length > 0;
  75. const groups = useMemo(
  76. () => (groupByQuery ? groupRoots(forest.rootVideos, queryLabels) : null),
  77. [groupByQuery, forest.rootVideos, queryLabels]
  78. );
  79. if (!hasTree) return <EmptyState label={emptyLabel} />;
  80. return (
  81. <section className="wt-page">
  82. <div className="wt-controls">
  83. <span className="wt-controls-label">展开</span>
  84. <button type="button" onClick={() => setDepth("collapse")}>只看起点</button>
  85. <button type="button" onClick={() => setDepth("routes")}>看到路线</button>
  86. <button type="button" onClick={() => setDepth("all")}>全部展开</button>
  87. </div>
  88. <div className="wt-tree" role="tree">
  89. {groups
  90. ? groups.map((group) => (
  91. <div className="wt-group" key={group.id || "ungrouped"}>
  92. <div className="wt-overview-head">
  93. <span className="wt-root-chip">搜索词</span>
  94. <strong>{group.label}</strong>
  95. </div>
  96. {group.batches.map((batch) => (
  97. <div className="wt-batch" key={batch.kind || "未分批"}>
  98. <div className="wt-batch-head">
  99. <span className="wt-batch-label">{batch.label}</span>
  100. <span className="wt-batch-count">{batch.videos.length} 条</span>
  101. {batch.gate ? <span className="wt-batch-gate">· {batch.gate}</span> : null}
  102. </div>
  103. <div className="wt-batch-videos">
  104. {batch.videos.map((node) => (
  105. <VideoRow
  106. node={node}
  107. depth={0}
  108. hideRootIndex
  109. runId={runId}
  110. expanded={expanded}
  111. onToggle={toggle}
  112. onOpenDrawer={setDrawer}
  113. key={node.key}
  114. />
  115. ))}
  116. </div>
  117. </div>
  118. ))}
  119. </div>
  120. ))
  121. : forest.rootVideos.map((node, index) => (
  122. <VideoRow
  123. node={node}
  124. depth={0}
  125. index={index}
  126. runId={runId}
  127. expanded={expanded}
  128. onToggle={toggle}
  129. onOpenDrawer={setDrawer}
  130. key={node.key}
  131. />
  132. ))}
  133. {forest.queryRoutes.map((node) => (
  134. <RouteRow
  135. node={node}
  136. depth={0}
  137. runId={runId}
  138. expanded={expanded}
  139. onToggle={toggle}
  140. onOpenDrawer={setDrawer}
  141. key={node.key}
  142. />
  143. ))}
  144. </div>
  145. <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
  146. </section>
  147. );
  148. }
  149. // ---------------------------------------------------------------------------
  150. // rows
  151. // ---------------------------------------------------------------------------
  152. function VideoRow({
  153. node,
  154. depth,
  155. index,
  156. hideRootIndex,
  157. runId,
  158. expanded,
  159. onToggle,
  160. onOpenDrawer
  161. }: {
  162. node: VideoNode;
  163. depth: number;
  164. index?: number;
  165. hideRootIndex?: boolean;
  166. runId: string;
  167. expanded: Set<string>;
  168. onToggle: (key: string) => void;
  169. onOpenDrawer: (drawer: BusinessDrawerState) => void;
  170. }) {
  171. const open = expanded.has(node.key);
  172. const hasRoutes = node.routes.length > 0;
  173. const walked = node.routes.filter((r) => isWalked(r.action)).length;
  174. const tone = decisionTone(node.decisionLabel);
  175. return (
  176. <div className="wt-branch" role="treeitem" aria-expanded={hasRoutes ? open : undefined}>
  177. <div className={`wt-row video ${tone}`}>
  178. <Toggle open={open} disabled={!hasRoutes} onClick={() => onToggle(node.key)} />
  179. <button type="button" className="wt-row-main" onClick={() => onOpenDrawer(videoDrawer(node))}>
  180. <span className={`wt-dot ${tone}`} />
  181. {depth === 0 && hideRootIndex ? null : (
  182. <span className="wt-kicker">{depth === 0 ? `起点 ${(index ?? 0) + 1}` : "游走到的"}</span>
  183. )}
  184. <span className="wt-title">{node.title}</span>
  185. <span className="wt-author">{node.author}</span>
  186. {node.score ? <span className="wt-score">{node.score}</span> : null}
  187. <span className={`wt-status ${tone}`}>{node.decisionLabel}</span>
  188. </button>
  189. <span className="wt-row-right">
  190. {hasRoutes ? (
  191. <span className={`wt-badge ${walked ? "go" : "stop"}`}>
  192. {walked ? `${walked}/${node.routes.length} 路线已游走` : `${node.routes.length} 路线未游走`}
  193. </span>
  194. ) : (
  195. <span className="wt-badge muted">无路线</span>
  196. )}
  197. <RowLinks video={node.video} runId={runId} />
  198. </span>
  199. </div>
  200. {open && hasRoutes ? (
  201. <div className="wt-children">
  202. {node.routes.map((route) => (
  203. <RouteRow
  204. node={route}
  205. depth={depth + 1}
  206. runId={runId}
  207. expanded={expanded}
  208. onToggle={onToggle}
  209. onOpenDrawer={onOpenDrawer}
  210. key={route.key}
  211. />
  212. ))}
  213. </div>
  214. ) : null}
  215. </div>
  216. );
  217. }
  218. function RouteRow({
  219. node,
  220. depth,
  221. runId,
  222. expanded,
  223. onToggle,
  224. onOpenDrawer
  225. }: {
  226. node: RouteNode;
  227. depth: number;
  228. runId: string;
  229. expanded: Set<string>;
  230. onToggle: (key: string) => void;
  231. onOpenDrawer: (drawer: BusinessDrawerState) => void;
  232. }) {
  233. const open = expanded.has(node.key);
  234. const walked = isWalked(node.action);
  235. const hasVideos = node.videos.length > 0;
  236. const edge = text(node.action.edge_id, "");
  237. const targetQuery = asRecord(node.action.target_query);
  238. return (
  239. <div className="wt-branch" role="treeitem" aria-expanded={hasVideos ? open : undefined}>
  240. <div className={`wt-row route ${edgeTone(edge)} ${walked ? "go" : "stop"}`}>
  241. <Toggle open={open} disabled={!hasVideos} onClick={() => onToggle(node.key)} />
  242. <button type="button" className="wt-row-main" onClick={() => onOpenDrawer(routeDrawer(node))}>
  243. <span className="wt-edge-icon">{edgeIcon(edge)}</span>
  244. <span className="wt-title route">{routeObject(node.action)}</span>
  245. {targetQuery.text && walked ? <span className="wt-newquery">→《{text(targetQuery.text)}》</span> : null}
  246. <span className={`wt-walk ${walked ? "go" : "stop"}`}>{walked ? "已游走" : "未游走"}</span>
  247. {!walked ? <span className="wt-reason">{shortReason(node.action)}</span> : null}
  248. </button>
  249. <span className="wt-row-right">
  250. {hasVideos ? <span className="wt-badge go">游走到 {node.videos.length} 条视频</span> : null}
  251. {targetQuery.id ? (
  252. <Link
  253. className="wt-link"
  254. href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(text(targetQuery.id))}/videos`}
  255. onClick={(e) => e.stopPropagation()}
  256. >
  257. 这些视频
  258. </Link>
  259. ) : null}
  260. </span>
  261. </div>
  262. {open && hasVideos ? (
  263. <div className="wt-children">
  264. {node.videos.map((video) => (
  265. <VideoRow
  266. node={video}
  267. depth={depth + 1}
  268. runId={runId}
  269. expanded={expanded}
  270. onToggle={onToggle}
  271. onOpenDrawer={onOpenDrawer}
  272. key={video.key}
  273. />
  274. ))}
  275. </div>
  276. ) : null}
  277. </div>
  278. );
  279. }
  280. function Toggle({ open, disabled, onClick }: { open: boolean; disabled: boolean; onClick: () => void }) {
  281. if (disabled) return <span className="wt-toggle empty" />;
  282. return (
  283. <button type="button" className="wt-toggle" aria-label={open ? "收起" : "展开"} onClick={onClick}>
  284. {open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
  285. </button>
  286. );
  287. }
  288. function RowLinks({ video, runId }: { video: RawRecord; runId: string }) {
  289. // platform content ids (视频号 finder ids) can contain / + = which break path routing;
  290. // prefer the clean content_discovery_id for the detail route.
  291. const detailId = maybeText(video.content_discovery_id) || text(video.id, "");
  292. if (!detailId) return null;
  293. const platformUrl = text(video.platform_url, "");
  294. const platformLabel = text(video.platform_label, "原视频");
  295. return (
  296. <span className="wt-links">
  297. <Link className="wt-link" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(detailId)}`} onClick={(e) => e.stopPropagation()}>
  298. 详情
  299. </Link>
  300. {platformUrl ? (
  301. <a className="wt-link" href={platformUrl} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>
  302. <ExternalLink size={11} />
  303. {platformLabel}
  304. </a>
  305. ) : null}
  306. </span>
  307. );
  308. }
  309. // ---------------------------------------------------------------------------
  310. // build
  311. // ---------------------------------------------------------------------------
  312. export function buildForest(actions: RawRecord[]): Forest {
  313. const routable = actions.filter((a) => !isTerminalEdge(text(a.edge_id, "")));
  314. const routesBySource = new Map<string, RawRecord[]>();
  315. const sourceIds = new Set<string>();
  316. const targetIds = new Set<string>();
  317. const queryRouteActions: RawRecord[] = [];
  318. routable.forEach((action) => {
  319. const sourceVideo = asRecord(action.source_video);
  320. const sid = text(sourceVideo.id, "");
  321. if (sid) {
  322. sourceIds.add(sid);
  323. const bucket = routesBySource.get(sid) || [];
  324. bucket.push(action);
  325. routesBySource.set(sid, bucket);
  326. } else {
  327. queryRouteActions.push(action);
  328. }
  329. asArray(action.target_videos)
  330. .filter(isRecord)
  331. .forEach((v) => {
  332. const vid = text(v.id, "");
  333. if (vid) targetIds.add(vid);
  334. });
  335. });
  336. const sourceVideoById = new Map<string, RawRecord>();
  337. routable.forEach((action) => {
  338. const sv = asRecord(action.source_video);
  339. const sid = text(sv.id, "");
  340. if (sid && !sourceVideoById.has(sid)) sourceVideoById.set(sid, sv);
  341. });
  342. const buildRoute = (action: RawRecord, parentKey: string, seen: Set<string>): RouteNode => {
  343. const key = `${parentKey}>r:${actionKey(action)}`;
  344. const videos = asArray(action.target_videos)
  345. .filter(isRecord)
  346. .map((v) => buildVideo(v, key, routesBySource, seen))
  347. .filter((v): v is VideoNode => v !== null);
  348. return { key, action, videos };
  349. };
  350. function buildVideo(video: RawRecord, parentKey: string, routes: Map<string, RawRecord[]>, seen: Set<string>): VideoNode | null {
  351. const id = text(video.id, "");
  352. const key = `${parentKey}>v:${id || Math.random().toString(36).slice(2)}`;
  353. const decision = asRecord(video.decision);
  354. const childRoutes = id && !seen.has(id) ? routes.get(id) || [] : [];
  355. const nextSeen = new Set(seen);
  356. if (id) nextSeen.add(id);
  357. return {
  358. key,
  359. id,
  360. searchQueryId: text(video.search_query_id, ""),
  361. batchKind: text(video.progressive_batch_kind, ""),
  362. pageNumber: Number(video.progressive_page_number) || 0,
  363. rankInPage: Number(video.progressive_item_rank_in_page) || 0,
  364. video,
  365. title: cleanTitle(text(video.title, "无标题视频")),
  366. author: text(video.author, "未知作者"),
  367. decisionLabel: text(decision.label, "未判断"),
  368. decisionAction: text(decision.action, ""),
  369. score: scoreText(decision),
  370. tags: asArray(video.tags).map((t) => text(t, "")).filter(Boolean),
  371. routes: childRoutes.map((a) => buildRoute(a, key, nextSeen))
  372. };
  373. }
  374. const rootVideos: VideoNode[] = [...sourceIds]
  375. .filter((id) => !targetIds.has(id))
  376. .map((id) => buildVideo(sourceVideoById.get(id) || { id }, "root", routesBySource, new Set<string>()))
  377. .filter((v): v is VideoNode => v !== null);
  378. const queryRoutes = queryRouteActions.map((a) => buildRoute(a, "root", new Set<string>()));
  379. return { rootVideos, queryRoutes };
  380. }
  381. function groupRoots(rootVideos: VideoNode[], queryLabels: Record<string, string>): QueryGroup[] {
  382. const map = new Map<string, VideoNode[]>();
  383. rootVideos.forEach((node) => {
  384. const qid = node.searchQueryId || "";
  385. if (!map.has(qid)) map.set(qid, []);
  386. map.get(qid)!.push(node);
  387. });
  388. return [...map.entries()].map(([id, videos]) => ({
  389. id,
  390. label: queryLabels[id] || (id ? id : "其它来源"),
  391. batches: groupBatches(videos)
  392. }));
  393. }
  394. function groupBatches(videos: VideoNode[]): BatchGroup[] {
  395. const map = new Map<string, VideoNode[]>();
  396. videos.forEach((node) => {
  397. const kind = node.batchKind || "";
  398. if (!map.has(kind)) map.set(kind, []);
  399. map.get(kind)!.push(node);
  400. });
  401. return [...map.entries()]
  402. .map(([kind, items]) => ({
  403. kind,
  404. label: batchLabel(kind),
  405. gate: batchGate(kind),
  406. videos: items.slice().sort((a, b) => a.rankInPage - b.rankInPage),
  407. sortKey: batchSortKey(kind)
  408. }))
  409. .sort((a, b) => a.sortKey - b.sortKey)
  410. .map(({ sortKey: _sortKey, ...rest }) => rest);
  411. }
  412. function batchSortKey(kind: string): number {
  413. if (kind === "initial_top3") return 10;
  414. if (kind === "page_remainder") return 11;
  415. const page = /^page_(\d+)$/.exec(kind);
  416. if (page) return Number(page[1]) * 10;
  417. if (kind === "author_works") return 9000;
  418. return 9999;
  419. }
  420. function batchLabel(kind: string): string {
  421. if (kind === "initial_top3") return "前 3 条";
  422. if (kind === "page_remainder") return "同页剩余";
  423. const page = /^page_(\d+)$/.exec(kind);
  424. if (page) return `第 ${Number(page[1])} 页`;
  425. if (kind === "author_works") return "作者作品";
  426. return "未分批";
  427. }
  428. function batchGate(kind: string): string {
  429. if (kind === "initial_top3") return "总是看";
  430. if (kind === "page_remainder") return "命中才看";
  431. if (/^page_\d+$/.test(kind)) return "命中才翻";
  432. return "";
  433. }
  434. function collectKeys(forest: Forest): { videoKeys: Set<string>; routeKeys: Set<string>; allKeys: Set<string> } {
  435. const videoKeys = new Set<string>();
  436. const routeKeys = new Set<string>();
  437. const walkVideo = (node: VideoNode) => {
  438. if (node.routes.length) videoKeys.add(node.key);
  439. node.routes.forEach(walkRoute);
  440. };
  441. const walkRoute = (node: RouteNode) => {
  442. if (node.videos.length) routeKeys.add(node.key);
  443. node.videos.forEach(walkVideo);
  444. };
  445. forest.rootVideos.forEach(walkVideo);
  446. forest.queryRoutes.forEach(walkRoute);
  447. return { videoKeys, routeKeys, allKeys: new Set([...videoKeys, ...routeKeys]) };
  448. }
  449. // ---------------------------------------------------------------------------
  450. // drawers
  451. // ---------------------------------------------------------------------------
  452. function videoDrawer(node: VideoNode): BusinessDrawerState {
  453. const decision = asRecord(node.video.decision);
  454. const scoreItems = asArray(decision.score_items).filter(isRecord);
  455. const reason = text(decision.reason_label, "");
  456. return {
  457. title: node.title,
  458. sections: [
  459. {
  460. label: "这条视频的判断",
  461. value: `${node.decisionLabel}${reason ? `:${reason}` : ""}`,
  462. tone: drawerTone(node.decisionLabel)
  463. },
  464. scoreItems.length
  465. ? {
  466. label: "评分明细",
  467. items: scoreItems.map((item) => `${text(item.label, "评分")}:${text(item.score_label, "未记录")}`),
  468. tone: "info"
  469. }
  470. : { label: "评分明细", value: "本条没有记录评分。", tone: "info" },
  471. node.tags.length ? { label: "视频标签", value: node.tags.join(" "), tone: "neutral" } : { label: "作者", value: node.author, tone: "neutral" }
  472. ]
  473. };
  474. }
  475. function routeDrawer(node: RouteNode): BusinessDrawerState {
  476. const action = node.action;
  477. const walked = isWalked(action);
  478. const detailLines = asArray(action.detail_lines).map((l) => text(l, "")).filter(Boolean);
  479. return {
  480. title: `${routeKindLabel(text(action.edge_id, ""))}`,
  481. sections: [
  482. {
  483. label: walked ? "这一步为什么继续" : "这一步为什么停下",
  484. value: walked
  485. ? `${routeObject(action)} 通过筛选,继续带回视频。`
  486. : `${routeObject(action)} 没有继续。原因:${shortReason(action)}。`,
  487. tone: walked ? "good" : "warn"
  488. },
  489. detailLines.length ? { label: "执行明细", items: detailLines, tone: "info" } : { label: "执行明细", value: text(action.summary, "没有更多明细。"), tone: "info" },
  490. { label: "对流程的影响", value: text(action.impact, walked ? "带回的视频会继续送去判断。" : "这条路线停止,不再带回新视频。"), tone: "neutral" }
  491. ]
  492. };
  493. }
  494. // ---------------------------------------------------------------------------
  495. // helpers
  496. // ---------------------------------------------------------------------------
  497. function isRecord(value: unknown): value is RawRecord {
  498. return typeof value === "object" && value !== null;
  499. }
  500. function actionKey(action: RawRecord): string {
  501. return text(action.id || action.walk_action_id || `${text(action.edge_id, "walk")}-${text(action.trigger_label, "")}`);
  502. }
  503. function isWalked(action: RawRecord): boolean {
  504. return text(action.status, "") === "success";
  505. }
  506. function isTerminalEdge(edgeId: string): boolean {
  507. return edgeId === "decision_to_asset" || edgeId === "budget_downgrade" || edgeId === "path_stop";
  508. }
  509. function cleanTitle(value: string): string {
  510. return value.replace(/#[^\s#]+/g, " ").replace(/\s+/g, " ").trim() || value;
  511. }
  512. function scoreText(decision: RawRecord): string {
  513. const label = text(decision.score_label, "");
  514. if (label && label !== "未记录") return label;
  515. const score = Number(decision.score);
  516. return Number.isFinite(score) && score > 0 ? `${Math.round(score)} 分` : "";
  517. }
  518. function decisionTone(label: string): string {
  519. if (label === "入池") return "good";
  520. if (label === "待复看") return "warn";
  521. if (label === "淘汰") return "bad";
  522. if (label.includes("审核拦截")) return "bad";
  523. if (label.includes("技术问题") || label.includes("重试") || label.includes("补跑")) return "tech";
  524. return "plain";
  525. }
  526. function drawerTone(label: string): BusinessTone {
  527. if (label === "入池") return "good";
  528. if (label === "淘汰") return "bad";
  529. return "warn";
  530. }
  531. function edgeTone(edgeId: string): string {
  532. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "tag";
  533. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "author";
  534. if (edgeId === "query_next_page") return "page";
  535. return "plain";
  536. }
  537. function edgeIcon(edgeId: string) {
  538. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return <Hash size={14} />;
  539. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return <UserSearch size={14} />;
  540. if (edgeId === "query_next_page") return <RotateCw size={14} />;
  541. return <Search size={14} />;
  542. }
  543. function routeKindLabel(edgeId: string): string {
  544. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "从视频标签继续游走";
  545. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "从作者继续游走";
  546. if (edgeId === "query_next_page") return "翻页继续找";
  547. return "继续游走";
  548. }
  549. function routeObject(action: RawRecord): string {
  550. const edge = text(action.edge_id, "");
  551. const raw = cleanBusinessText(text(action.trigger_label, ""));
  552. if (edge === "query_next_page") return "翻下一页";
  553. if (edge === "hashtag_to_query" || edge === "tag_query") {
  554. const target = asRecord(action.target_query);
  555. if (target.text && isWalked(action)) return `标签`;
  556. const candidates = raw.replace(/^候选标签:/, "").split("/")[0]?.trim();
  557. return candidates ? `标签 ${candidates}` : "标签";
  558. }
  559. if (edge === "author_to_works" || edge === "author_works" || edge === "video_to_author") {
  560. return `作者 ${raw.replace(/^作者:/, "") || "待确认"}`;
  561. }
  562. return raw || "继续游走";
  563. }
  564. function shortReason(action: RawRecord): string {
  565. const reason = cleanBusinessText(text(action.reason_label, ""));
  566. // technical retry: the source video failed a technical step (Gemini/play_url/OSS),
  567. // surface the concrete cause instead of the generic sentence.
  568. if (reason.includes("重试")) {
  569. const td = asRecord(asRecord(action.source_video).technical_retry_detail);
  570. const brief = maybeText(td.brief_reason);
  571. const retry = Number(td.retry_count);
  572. const suffix = Number.isFinite(retry) && retry > 0 ? `,已重试 ${retry} 次` : "";
  573. return brief ? `判定失败待重试:${brief}${suffix}` : "判定失败,需重试后再判断";
  574. }
  575. if (!reason) return "未达到继续扩展要求";
  576. if (reason.includes("游走次数上限") || reason.includes("次数上限")) return "已达游走次数上限";
  577. if (reason.includes("复看")) return "分数在待复看区间,未达入池线(先不向外扩展)";
  578. return reason.replace(/^不适合继续向外扩展:?/, "");
  579. }
  580. function cleanBusinessText(value: string): string {
  581. if (!value || value === "系统原因待补充" || value === "这一步没有记录额外原因") return "";
  582. return value.replaceAll("“", "《").replaceAll("”", "》");
  583. }