WalkTree.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  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. <div className="wt-row-main">
  180. <button
  181. type="button"
  182. className="wt-summary-trigger"
  183. onClick={() => onOpenDrawer(videoDrawer(node))}
  184. aria-label={`查看摘要:${node.title}`}
  185. title="查看摘要"
  186. >
  187. <span className={`wt-dot ${tone}`} />
  188. {depth === 0 && hideRootIndex ? null : (
  189. <span className="wt-kicker">{depth === 0 ? `起点 ${(index ?? 0) + 1}` : "游走到的"}</span>
  190. )}
  191. </button>
  192. <VideoTitleLink runId={runId} video={node.video} title={node.title} />
  193. <span className="wt-author">{node.author}</span>
  194. {node.score ? <span className="wt-score">{node.score}</span> : null}
  195. <span className={`wt-status ${tone}`}>{node.decisionLabel}</span>
  196. </div>
  197. <span className="wt-row-right">
  198. {hasRoutes ? (
  199. <span className={`wt-badge ${walked ? "go" : "stop"}`}>
  200. {walked ? `${walked}/${node.routes.length} 路线已游走` : `${node.routes.length} 路线未游走`}
  201. </span>
  202. ) : (
  203. <span className="wt-badge muted">无路线</span>
  204. )}
  205. <RowLinks video={node.video} runId={runId} />
  206. </span>
  207. </div>
  208. {open && hasRoutes ? (
  209. <div className="wt-children">
  210. {node.routes.map((route) => (
  211. <RouteRow
  212. node={route}
  213. depth={depth + 1}
  214. runId={runId}
  215. expanded={expanded}
  216. onToggle={onToggle}
  217. onOpenDrawer={onOpenDrawer}
  218. key={route.key}
  219. />
  220. ))}
  221. </div>
  222. ) : null}
  223. </div>
  224. );
  225. }
  226. function RouteRow({
  227. node,
  228. depth,
  229. runId,
  230. expanded,
  231. onToggle,
  232. onOpenDrawer
  233. }: {
  234. node: RouteNode;
  235. depth: number;
  236. runId: string;
  237. expanded: Set<string>;
  238. onToggle: (key: string) => void;
  239. onOpenDrawer: (drawer: BusinessDrawerState) => void;
  240. }) {
  241. const open = expanded.has(node.key);
  242. const walked = isWalked(node.action);
  243. const hasVideos = node.videos.length > 0;
  244. const edge = text(node.action.edge_id, "");
  245. const targetQuery = asRecord(node.action.target_query);
  246. return (
  247. <div className="wt-branch" role="treeitem" aria-expanded={hasVideos ? open : undefined}>
  248. <div className={`wt-row route ${edgeTone(edge)} ${walked ? "go" : "stop"}`}>
  249. <Toggle open={open} disabled={!hasVideos} onClick={() => onToggle(node.key)} />
  250. <button type="button" className="wt-row-main" onClick={() => onOpenDrawer(routeDrawer(node))}>
  251. <span className="wt-edge-icon">{edgeIcon(edge)}</span>
  252. <span className="wt-title route">{routeObject(node.action)}</span>
  253. {targetQuery.text && walked ? <span className="wt-newquery">→《{text(targetQuery.text)}》</span> : null}
  254. <span className={`wt-walk ${walked ? "go" : "stop"}`}>{walked ? "已游走" : "未游走"}</span>
  255. {!walked ? <span className="wt-reason">{shortReason(node.action)}</span> : null}
  256. </button>
  257. <span className="wt-row-right">
  258. {hasVideos ? <span className="wt-badge go">游走到 {node.videos.length} 条视频</span> : null}
  259. {targetQuery.id ? (
  260. <Link
  261. className="wt-link"
  262. href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(text(targetQuery.id))}/videos`}
  263. onClick={(e) => e.stopPropagation()}
  264. >
  265. 这些视频
  266. </Link>
  267. ) : null}
  268. </span>
  269. </div>
  270. {open && hasVideos ? (
  271. <div className="wt-children">
  272. {node.videos.map((video) => (
  273. <VideoRow
  274. node={video}
  275. depth={depth + 1}
  276. runId={runId}
  277. expanded={expanded}
  278. onToggle={onToggle}
  279. onOpenDrawer={onOpenDrawer}
  280. key={video.key}
  281. />
  282. ))}
  283. </div>
  284. ) : null}
  285. </div>
  286. );
  287. }
  288. function Toggle({ open, disabled, onClick }: { open: boolean; disabled: boolean; onClick: () => void }) {
  289. if (disabled) return <span className="wt-toggle empty" />;
  290. return (
  291. <button type="button" className="wt-toggle" aria-label={open ? "收起" : "展开"} onClick={onClick}>
  292. {open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
  293. </button>
  294. );
  295. }
  296. function RowLinks({ video, runId }: { video: RawRecord; runId: string }) {
  297. const detailHref = videoDetailHref(runId, video);
  298. if (!detailHref) return null;
  299. const platformUrl = text(video.platform_url, "");
  300. const platformLabel = text(video.platform_label, "原视频");
  301. return (
  302. <span className="wt-links">
  303. <Link className="wt-link" href={detailHref} onClick={(e) => e.stopPropagation()}>
  304. 详情
  305. </Link>
  306. {platformUrl ? (
  307. <a className="wt-link" href={platformUrl} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>
  308. <ExternalLink size={11} />
  309. {platformLabel}
  310. </a>
  311. ) : null}
  312. </span>
  313. );
  314. }
  315. function VideoTitleLink({ runId, video, title }: { runId: string; video: RawRecord; title: string }) {
  316. const detailHref = videoDetailHref(runId, video);
  317. if (!detailHref) return <span className="wt-title">{title}</span>;
  318. return (
  319. <Link className="wt-title wt-title-link" href={detailHref}>
  320. {title}
  321. </Link>
  322. );
  323. }
  324. function videoDetailHref(runId: string, video: RawRecord): string {
  325. // platform content ids (视频号 finder ids) can contain / + = which break path routing;
  326. // prefer the clean content_discovery_id for the detail route.
  327. const detailId = maybeText(video.content_discovery_id) || text(video.id, "");
  328. return detailId
  329. ? `/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(detailId)}`
  330. : "";
  331. }
  332. // ---------------------------------------------------------------------------
  333. // build
  334. // ---------------------------------------------------------------------------
  335. export function buildForest(actions: RawRecord[]): Forest {
  336. const routable = actions.filter((a) => !isTerminalEdge(text(a.edge_id, "")));
  337. const routesBySource = new Map<string, RawRecord[]>();
  338. const sourceIds = new Set<string>();
  339. const targetIds = new Set<string>();
  340. const queryRouteActions: RawRecord[] = [];
  341. routable.forEach((action) => {
  342. const sourceVideo = asRecord(action.source_video);
  343. const sid = text(sourceVideo.id, "");
  344. if (sid) {
  345. sourceIds.add(sid);
  346. const bucket = routesBySource.get(sid) || [];
  347. bucket.push(action);
  348. routesBySource.set(sid, bucket);
  349. } else {
  350. queryRouteActions.push(action);
  351. }
  352. asArray(action.target_videos)
  353. .filter(isRecord)
  354. .forEach((v) => {
  355. const vid = text(v.id, "");
  356. if (vid) targetIds.add(vid);
  357. });
  358. });
  359. const sourceVideoById = new Map<string, RawRecord>();
  360. routable.forEach((action) => {
  361. const sv = asRecord(action.source_video);
  362. const sid = text(sv.id, "");
  363. if (sid && !sourceVideoById.has(sid)) sourceVideoById.set(sid, sv);
  364. });
  365. const buildRoute = (action: RawRecord, parentKey: string, seen: Set<string>): RouteNode => {
  366. const key = `${parentKey}>r:${actionKey(action)}`;
  367. const videos = asArray(action.target_videos)
  368. .filter(isRecord)
  369. .map((v) => buildVideo(v, key, routesBySource, seen))
  370. .filter((v): v is VideoNode => v !== null);
  371. return { key, action, videos };
  372. };
  373. function buildVideo(video: RawRecord, parentKey: string, routes: Map<string, RawRecord[]>, seen: Set<string>): VideoNode | null {
  374. const id = text(video.id, "");
  375. const key = `${parentKey}>v:${id || Math.random().toString(36).slice(2)}`;
  376. const decision = asRecord(video.decision);
  377. const childRoutes = id && !seen.has(id) ? routes.get(id) || [] : [];
  378. const nextSeen = new Set(seen);
  379. if (id) nextSeen.add(id);
  380. return {
  381. key,
  382. id,
  383. searchQueryId: text(video.search_query_id, ""),
  384. batchKind: text(video.progressive_batch_kind, ""),
  385. pageNumber: Number(video.progressive_page_number) || 0,
  386. rankInPage: Number(video.progressive_item_rank_in_page) || 0,
  387. video,
  388. title: cleanTitle(text(video.title, "无标题视频")),
  389. author: text(video.author, "未知作者"),
  390. decisionLabel: text(decision.label, "未判断"),
  391. decisionAction: text(decision.action, ""),
  392. score: scoreText(decision),
  393. tags: asArray(video.tags).map((t) => text(t, "")).filter(Boolean),
  394. routes: childRoutes.map((a) => buildRoute(a, key, nextSeen))
  395. };
  396. }
  397. const rootVideos: VideoNode[] = [...sourceIds]
  398. .filter((id) => !targetIds.has(id))
  399. .map((id) => buildVideo(sourceVideoById.get(id) || { id }, "root", routesBySource, new Set<string>()))
  400. .filter((v): v is VideoNode => v !== null);
  401. const queryRoutes = queryRouteActions.map((a) => buildRoute(a, "root", new Set<string>()));
  402. return { rootVideos, queryRoutes };
  403. }
  404. function groupRoots(rootVideos: VideoNode[], queryLabels: Record<string, string>): QueryGroup[] {
  405. const map = new Map<string, VideoNode[]>();
  406. rootVideos.forEach((node) => {
  407. const qid = node.searchQueryId || "";
  408. if (!map.has(qid)) map.set(qid, []);
  409. map.get(qid)!.push(node);
  410. });
  411. return [...map.entries()].map(([id, videos]) => ({
  412. id,
  413. label: queryLabels[id] || (id ? id : "其它来源"),
  414. batches: groupBatches(videos)
  415. }));
  416. }
  417. function groupBatches(videos: VideoNode[]): BatchGroup[] {
  418. const map = new Map<string, VideoNode[]>();
  419. videos.forEach((node) => {
  420. const kind = node.batchKind || "";
  421. if (!map.has(kind)) map.set(kind, []);
  422. map.get(kind)!.push(node);
  423. });
  424. return [...map.entries()]
  425. .map(([kind, items]) => ({
  426. kind,
  427. label: batchLabel(kind),
  428. gate: batchGate(kind),
  429. videos: items.slice().sort((a, b) => a.rankInPage - b.rankInPage),
  430. sortKey: batchSortKey(kind)
  431. }))
  432. .sort((a, b) => a.sortKey - b.sortKey)
  433. .map(({ sortKey: _sortKey, ...rest }) => rest);
  434. }
  435. function batchSortKey(kind: string): number {
  436. if (kind === "initial_top3") return 10;
  437. if (kind === "page_remainder") return 11;
  438. const page = /^page_(\d+)$/.exec(kind);
  439. if (page) return Number(page[1]) * 10;
  440. if (kind === "author_works") return 9000;
  441. return 9999;
  442. }
  443. function batchLabel(kind: string): string {
  444. if (kind === "initial_top3") return "前 3 条";
  445. if (kind === "page_remainder") return "同页剩余";
  446. const page = /^page_(\d+)$/.exec(kind);
  447. if (page) return `第 ${Number(page[1])} 页`;
  448. if (kind === "author_works") return "作者作品";
  449. return "未分批";
  450. }
  451. function batchGate(kind: string): string {
  452. if (kind === "initial_top3") return "总是看";
  453. if (kind === "page_remainder") return "命中才看";
  454. if (/^page_\d+$/.test(kind)) return "命中才翻";
  455. return "";
  456. }
  457. function collectKeys(forest: Forest): { videoKeys: Set<string>; routeKeys: Set<string>; allKeys: Set<string> } {
  458. const videoKeys = new Set<string>();
  459. const routeKeys = new Set<string>();
  460. const walkVideo = (node: VideoNode) => {
  461. if (node.routes.length) videoKeys.add(node.key);
  462. node.routes.forEach(walkRoute);
  463. };
  464. const walkRoute = (node: RouteNode) => {
  465. if (node.videos.length) routeKeys.add(node.key);
  466. node.videos.forEach(walkVideo);
  467. };
  468. forest.rootVideos.forEach(walkVideo);
  469. forest.queryRoutes.forEach(walkRoute);
  470. return { videoKeys, routeKeys, allKeys: new Set([...videoKeys, ...routeKeys]) };
  471. }
  472. // ---------------------------------------------------------------------------
  473. // drawers
  474. // ---------------------------------------------------------------------------
  475. function videoDrawer(node: VideoNode): BusinessDrawerState {
  476. const decision = asRecord(node.video.decision);
  477. const scoreItems = asArray(decision.score_items).filter(isRecord);
  478. const reason = text(decision.reason_label, "");
  479. return {
  480. title: node.title,
  481. sections: [
  482. {
  483. label: "这条视频的判断",
  484. value: `${node.decisionLabel}${reason ? `:${reason}` : ""}`,
  485. tone: drawerTone(node.decisionLabel)
  486. },
  487. scoreItems.length
  488. ? {
  489. label: "评分明细",
  490. items: scoreItems.map((item) => `${text(item.label, "评分")}:${text(item.score_label, "未记录")}`),
  491. tone: "info"
  492. }
  493. : { label: "评分明细", value: "本条没有记录评分。", tone: "info" },
  494. node.tags.length ? { label: "视频标签", value: node.tags.join(" "), tone: "neutral" } : { label: "作者", value: node.author, tone: "neutral" }
  495. ]
  496. };
  497. }
  498. function routeDrawer(node: RouteNode): BusinessDrawerState {
  499. const action = node.action;
  500. const walked = isWalked(action);
  501. const detailLines = asArray(action.detail_lines).map((l) => text(l, "")).filter(Boolean);
  502. return {
  503. title: `${routeKindLabel(text(action.edge_id, ""))}`,
  504. sections: [
  505. {
  506. label: walked ? "这一步为什么继续" : "这一步为什么停下",
  507. value: walked
  508. ? `${routeObject(action)} 通过筛选,继续带回视频。`
  509. : `${routeObject(action)} 没有继续。原因:${shortReason(action)}。`,
  510. tone: walked ? "good" : "warn"
  511. },
  512. detailLines.length ? { label: "执行明细", items: detailLines, tone: "info" } : { label: "执行明细", value: text(action.summary, "没有更多明细。"), tone: "info" },
  513. { label: "对流程的影响", value: text(action.impact, walked ? "带回的视频会继续送去判断。" : "这条路线停止,不再带回新视频。"), tone: "neutral" }
  514. ]
  515. };
  516. }
  517. // ---------------------------------------------------------------------------
  518. // helpers
  519. // ---------------------------------------------------------------------------
  520. function isRecord(value: unknown): value is RawRecord {
  521. return typeof value === "object" && value !== null;
  522. }
  523. function actionKey(action: RawRecord): string {
  524. return text(action.id || action.walk_action_id || `${text(action.edge_id, "walk")}-${text(action.trigger_label, "")}`);
  525. }
  526. function isWalked(action: RawRecord): boolean {
  527. return text(action.status, "") === "success";
  528. }
  529. function isTerminalEdge(edgeId: string): boolean {
  530. return edgeId === "decision_to_asset" || edgeId === "budget_downgrade" || edgeId === "path_stop";
  531. }
  532. function cleanTitle(value: string): string {
  533. return value.replace(/#[^\s#]+/g, " ").replace(/\s+/g, " ").trim() || value;
  534. }
  535. function scoreText(decision: RawRecord): string {
  536. const label = text(decision.score_label, "");
  537. if (label && label !== "未记录") return label;
  538. const score = Number(decision.score);
  539. return Number.isFinite(score) && score > 0 ? `${Math.round(score)} 分` : "";
  540. }
  541. function decisionTone(label: string): string {
  542. if (label === "入池") return "good";
  543. if (label === "待复看") return "warn";
  544. if (label === "淘汰") return "bad";
  545. if (label.includes("审核拦截")) return "bad";
  546. if (label.includes("技术问题") || label.includes("重试") || label.includes("补跑")) return "tech";
  547. return "plain";
  548. }
  549. function drawerTone(label: string): BusinessTone {
  550. if (label === "入池") return "good";
  551. if (label === "淘汰") return "bad";
  552. return "warn";
  553. }
  554. function edgeTone(edgeId: string): string {
  555. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "tag";
  556. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "author";
  557. if (edgeId === "query_next_page") return "page";
  558. return "plain";
  559. }
  560. function edgeIcon(edgeId: string) {
  561. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return <Hash size={14} />;
  562. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return <UserSearch size={14} />;
  563. if (edgeId === "query_next_page") return <RotateCw size={14} />;
  564. return <Search size={14} />;
  565. }
  566. function routeKindLabel(edgeId: string): string {
  567. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "从视频标签继续游走";
  568. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "从作者继续游走";
  569. if (edgeId === "query_next_page") return "翻页继续找";
  570. return "继续游走";
  571. }
  572. function routeObject(action: RawRecord): string {
  573. const edge = text(action.edge_id, "");
  574. const raw = cleanBusinessText(text(action.trigger_label, ""));
  575. if (edge === "query_next_page") return "翻下一页";
  576. if (edge === "hashtag_to_query" || edge === "tag_query") {
  577. const target = asRecord(action.target_query);
  578. if (target.text && isWalked(action)) return `标签`;
  579. const candidates = raw.replace(/^候选标签:/, "").split("/")[0]?.trim();
  580. return candidates ? `标签 ${candidates}` : "标签";
  581. }
  582. if (edge === "author_to_works" || edge === "author_works" || edge === "video_to_author") {
  583. return `作者 ${raw.replace(/^作者:/, "") || "待确认"}`;
  584. }
  585. return raw || "继续游走";
  586. }
  587. function shortReason(action: RawRecord): string {
  588. const reason = cleanBusinessText(text(action.reason_label, ""));
  589. // technical retry: the source video failed a technical step (Gemini/play_url/OSS),
  590. // surface the concrete cause instead of the generic sentence.
  591. if (reason.includes("重试")) {
  592. const td = asRecord(asRecord(action.source_video).technical_retry_detail);
  593. const brief = maybeText(td.brief_reason);
  594. const retry = Number(td.retry_count);
  595. const suffix = Number.isFinite(retry) && retry > 0 ? `,已重试 ${retry} 次` : "";
  596. return brief ? `判定失败待重试:${brief}${suffix}` : "判定失败,需重试后再判断";
  597. }
  598. if (!reason) return "未达到继续扩展要求";
  599. if (reason.includes("游走次数上限") || reason.includes("次数上限")) return "已达游走次数上限";
  600. if (reason.includes("复看")) return "分数在待复看区间,未达入池线(先不向外扩展)";
  601. return reason.replace(/^不适合继续向外扩展:?/, "");
  602. }
  603. function cleanBusinessText(value: string): string {
  604. if (!value || value === "系统原因待补充" || value === "这一步没有记录额外原因") return "";
  605. return value.replaceAll("“", "《").replaceAll("”", "》");
  606. }