QueryWalkPage.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. "use client";
  2. import Link from "next/link";
  3. import { ExternalLink, Hash, Info, ListVideo, Search, UserSearch } from "lucide-react";
  4. import { useCallback, useEffect, useMemo, useState } from "react";
  5. import { AppShell } from "@/components/AppShell";
  6. import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
  7. import { EmptyState, ErrorState, LoadingState } from "@/components/StateBlocks";
  8. import { getFlowLedgerWalk } from "@/lib/api/client";
  9. import type { FlowLedgerWalkResponse, RawRecord } from "@/lib/api/types";
  10. import { methodLabel } from "@/lib/flow-ledger/business";
  11. import { asRecord, text } from "@/lib/flow-ledger/format";
  12. type WalkGroup = {
  13. id: string;
  14. kind: "video" | "query";
  15. video?: RawRecord;
  16. title: string;
  17. author: string;
  18. decisionLabel: string;
  19. terminalLabel: string;
  20. actions: RawRecord[];
  21. };
  22. type WalkTree = {
  23. roots: WalkGroup[];
  24. childrenByActionId: Map<string, WalkGroup[]>;
  25. totalGroups: number;
  26. };
  27. export function QueryWalkPage({ runId, queryId }: { runId: string; queryId: string }) {
  28. const [data, setData] = useState<FlowLedgerWalkResponse | null>(null);
  29. const [loading, setLoading] = useState(true);
  30. const [error, setError] = useState<string | null>(null);
  31. const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
  32. const load = useCallback(async () => {
  33. setLoading(true);
  34. setError(null);
  35. try {
  36. const debug = new URLSearchParams(window.location.search).get("debug") === "1";
  37. setData(await getFlowLedgerWalk(runId, queryId, debug));
  38. } catch (err) {
  39. setError(err instanceof Error ? err.message : String(err));
  40. } finally {
  41. setLoading(false);
  42. }
  43. }, [queryId, runId]);
  44. useEffect(() => {
  45. void load();
  46. }, [load]);
  47. const query = asRecord(data?.query);
  48. const actions = useMemo(() => [...(data?.actions || [])].sort((a, b) => actionDepth(a) - actionDepth(b)), [data?.actions]);
  49. const tree = useMemo(() => buildWalkTree(actions), [actions]);
  50. return (
  51. <AppShell title="继续扩展过程" subtitle={query.text ? `搜索词:${text(query.text)}` : "查看系统有没有继续向外找内容"} runId={runId} showBack onRefresh={load}>
  52. {loading ? <LoadingState /> : null}
  53. {error ? <ErrorState message={error} /> : null}
  54. {!loading && !error && !actions.length ? <EmptyState label="这个搜索词本轮没有继续扩展" /> : null}
  55. {data ? (
  56. <>
  57. <details className="declarations" open>
  58. <summary>
  59. <span className="kw">游走</span> <b>{text(query.text, "搜索词待确认")}</b>
  60. <span className="decl-purpose">{methodLabel(text(query.method, ""))}</span>
  61. <span className="tag-mini">{tree.totalGroups ? `${tree.totalGroups} 个起点` : "没有继续扩展"}</span>
  62. </summary>
  63. <div className="decl-body">
  64. <Summary label="怎么看" value="先看每次游走用了什么标签、作者或翻页;再看它带回的视频是否入池、待复看或淘汰。带回的视频如果继续游走,会在视频卡下面继续缩进展开。" />
  65. <Summary label="本轮概况" value={`已游走 ${Number(data.summary.success_count || 0)} 条路线 · 未游走 ${Number(data.summary.skipped_count || 0)} 条路线 · 新增搜索词 ${Number(data.summary.extension_query_count || 0)} 个`} />
  66. </div>
  67. </details>
  68. <section className="walk-ledger-board">
  69. <div className="walk-ledger-root">
  70. <span className="chip dark">当前搜索词</span>
  71. <strong>{text(query.text, "搜索词待确认")}</strong>
  72. <small>{methodLabel(text(query.method, ""))}</small>
  73. </div>
  74. <div className="walk-ledger-groups">
  75. {tree.roots.map((group, index) => (
  76. <WalkGroupCard group={group} index={index} runId={runId} tree={tree} level={0} onOpenScore={setDrawer} key={group.id} />
  77. ))}
  78. </div>
  79. </section>
  80. <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
  81. </>
  82. ) : null}
  83. </AppShell>
  84. );
  85. }
  86. function Summary({ label, value }: { label: string; value: string }) {
  87. return (
  88. <div className="decl-section">
  89. <div className="decl-label">{label}</div>
  90. <div className="decl-row">{value}</div>
  91. </div>
  92. );
  93. }
  94. function WalkGroupCard({
  95. group,
  96. index,
  97. runId,
  98. tree,
  99. level,
  100. onOpenScore,
  101. seen = new Set<string>()
  102. }: {
  103. group: WalkGroup;
  104. index: number;
  105. runId: string;
  106. tree: WalkTree;
  107. level: number;
  108. onOpenScore: (drawer: BusinessDrawerState) => void;
  109. seen?: Set<string>;
  110. }) {
  111. const routeActions = group.actions.filter((action) => !isTerminalEdge(text(action.edge_id, "")));
  112. const titleParts = splitTitleAndTags(group.title);
  113. const score = group.video ? scoreSummary(asRecord(group.video.decision).score_items) : "";
  114. const nextSeen = new Set(seen).add(group.id);
  115. return (
  116. <article className={`walk-story-block level-${Math.min(level, 3)}`} id={`source-${encodeURIComponent(group.id)}`}>
  117. <header className="walk-story-head">
  118. <span className="walk-source-kicker">{sourceKicker(group, index, level)}</span>
  119. <div className="walk-story-title">
  120. <h2>{titleParts.title}</h2>
  121. {titleParts.tags.length ? (
  122. <div className="walk-source-tags">
  123. {titleParts.tags.map((tag) => <span key={tag}>{tag}</span>)}
  124. </div>
  125. ) : null}
  126. </div>
  127. <VideoLinks video={group.video} runId={runId} />
  128. </header>
  129. <div className="walk-source-facts">
  130. {group.kind === "video" ? <Fact label="作者" value={group.author} /> : <Fact label="动作" value="搜索词翻页" tone="info" />}
  131. {group.kind === "video" ? <Fact label="判断" value={group.decisionLabel} tone={decisionTone(group.decisionLabel)} /> : <Fact label="起点" value={querySourceName(group.title)} />}
  132. {score ? <Fact label="评分" value={score} /> : null}
  133. {group.terminalLabel ? <Fact label="处理" value={group.terminalLabel} /> : null}
  134. </div>
  135. <section className="walk-story-routes" aria-label={`${group.title} 的游走路线`}>
  136. <div className="walk-story-routes-label">{group.kind === "video" ? "从这条视频继续往外找" : "从这个搜索词翻下一页"}</div>
  137. {routeActions.length ? routeActions.map((action, actionIndex) => (
  138. <WalkRouteCard
  139. action={action}
  140. runId={runId}
  141. sourceId={group.id}
  142. tree={tree}
  143. level={level}
  144. onOpenScore={onOpenScore}
  145. seen={nextSeen}
  146. key={`${text(action.id, "walk")}-${actionIndex}`}
  147. />
  148. )) : <div className="walk-no-route">{group.kind === "video" ? "这条视频没有继续产生标签、作者或翻页路线。" : "这个搜索词没有继续翻页或产生新路线。"}</div>}
  149. </section>
  150. </article>
  151. );
  152. }
  153. function Fact({ label, value, tone = "plain" }: { label: string; value: string; tone?: string }) {
  154. return (
  155. <div className={`walk-fact ${tone}`}>
  156. <span>{label}</span>
  157. <strong>{value || "待确认"}</strong>
  158. </div>
  159. );
  160. }
  161. function WalkRouteCard({
  162. action,
  163. runId,
  164. sourceId,
  165. tree,
  166. level,
  167. onOpenScore,
  168. seen
  169. }: {
  170. action: RawRecord;
  171. runId: string;
  172. sourceId: string;
  173. tree: WalkTree;
  174. level: number;
  175. onOpenScore: (drawer: BusinessDrawerState) => void;
  176. seen: Set<string>;
  177. }) {
  178. const edgeId = text(action.edge_id, "");
  179. const targetQuery = asRecord(action.target_query);
  180. const targetVideos = Array.isArray(action.target_videos) ? action.target_videos.filter(isRecord) : [];
  181. const status = text(action.status, "");
  182. const isSuccess = status === "success";
  183. const routeName = routeLabel(edgeId);
  184. const gate = gateText(action, isSuccess);
  185. const childGroups = (tree.childrenByActionId.get(actionKey(action)) || []).filter((group) => !seen.has(group.id));
  186. const childByVideoId = new Map(childGroups.map((group) => [group.id, group]));
  187. return (
  188. <article className={`walk-tree-route ${edgeTone(edgeId)} ${isSuccess ? "success" : "stopped"}`} id={`route-${encodeURIComponent(text(action.id, routeName))}`}>
  189. <div className="walk-tree-route-dot" />
  190. <div className="walk-tree-route-body">
  191. <div className="walk-route-head">
  192. <span className="walk-edge-icon">{edgeIcon(edgeId)}</span>
  193. <div>
  194. <strong>{routeName}</strong>
  195. <p>{routeSummary(action, targetQuery, targetVideos, isSuccess)}</p>
  196. </div>
  197. <span className={`chip ${isSuccess ? "green" : "yellow"}`}>{isSuccess ? "已游走" : "未游走"}</span>
  198. </div>
  199. <div className="walk-route-bottom">
  200. <span className={`walk-gate ${isSuccess ? "pass" : "stop"}`}>{gate}</span>
  201. <button className="walk-jump-button" type="button" onClick={() => onOpenScore(scoreDrawer(action, routeName, isSuccess))}>
  202. <Info size={13} />
  203. 看筛选口径
  204. </button>
  205. {targetQuery.id ? (
  206. <Link className="walk-jump-button" href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(text(targetQuery.id, ""))}/videos`}>
  207. 查看带回视频
  208. </Link>
  209. ) : null}
  210. <a className="walk-jump-button" href={`#source-${encodeURIComponent(sourceId)}`}>回到起点</a>
  211. </div>
  212. {targetVideos.length ? (
  213. <div className="walk-result-videos">
  214. <div className="walk-result-title">
  215. <ListVideo size={15} />
  216. <span>带回视频 {targetVideos.length} 条</span>
  217. </div>
  218. <div className="walk-result-video-grid">
  219. {targetVideos.map((video, videoIndex) => (
  220. <WalkResultVideoCard
  221. video={video}
  222. runId={runId}
  223. tree={tree}
  224. level={level}
  225. seen={seen}
  226. child={childByVideoId.get(text(video.id, ""))}
  227. onOpenScore={onOpenScore}
  228. key={text(video.id, `${text(video.title, "video")}-${videoIndex}`)}
  229. />
  230. ))}
  231. </div>
  232. </div>
  233. ) : !isSuccess ? (
  234. <div className="walk-stop-note">{stoppedSentence(action)}</div>
  235. ) : null}
  236. </div>
  237. </article>
  238. );
  239. }
  240. function WalkResultVideoCard({
  241. video,
  242. runId,
  243. tree,
  244. level,
  245. seen,
  246. child,
  247. onOpenScore
  248. }: {
  249. video: RawRecord;
  250. runId: string;
  251. tree: WalkTree;
  252. level: number;
  253. seen: Set<string>;
  254. child?: WalkGroup;
  255. onOpenScore: (drawer: BusinessDrawerState) => void;
  256. }) {
  257. const decision = asRecord(video.decision);
  258. const label = text(decision.label, "未判断");
  259. const scores = videoScoreItems(video);
  260. const titleParts = splitTitleAndTags(text(video.title, "无标题视频"));
  261. return (
  262. <article className={`walk-result-video-card ${decisionTone(label)} ${child ? "has-child" : ""}`}>
  263. <div className="walk-result-video-top">
  264. <span className={`walk-result-status ${decisionTone(label)}`}>{label}</span>
  265. {child ? <span className="walk-result-continue">继续游走</span> : <span className="walk-result-stop">{stopLabel(label)}</span>}
  266. </div>
  267. <Link className="walk-result-video-title" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(text(video.id, ""))}`}>
  268. {titleParts.title}
  269. </Link>
  270. {titleParts.tags.length ? (
  271. <div className="walk-source-tags compact-tags">
  272. {titleParts.tags.slice(0, 4).map((tag) => <span key={tag}>{tag}</span>)}
  273. </div>
  274. ) : null}
  275. <div className="walk-result-video-meta">{text(video.author, "未知作者")}</div>
  276. {scores.length ? (
  277. <div className="walk-score-chips">
  278. {scores.slice(0, 3).map((item) => (
  279. <span key={item.label}>{shortScoreLabel(item.label)} {item.score}</span>
  280. ))}
  281. </div>
  282. ) : null}
  283. <VideoLinks video={video} runId={runId} compact />
  284. {child ? (
  285. <div className="walk-card-children">
  286. <WalkGroupCard
  287. group={child}
  288. index={0}
  289. runId={runId}
  290. tree={tree}
  291. level={level + 1}
  292. seen={seen}
  293. onOpenScore={onOpenScore}
  294. />
  295. </div>
  296. ) : null}
  297. </article>
  298. );
  299. }
  300. function buildWalkTree(actions: RawRecord[]): WalkTree {
  301. const groups = buildWalkGroups(actions);
  302. const byId = new Map(groups.map((group) => [group.id, group]));
  303. const childrenByActionId = new Map<string, WalkGroup[]>();
  304. const childIds = new Set<string>();
  305. groups.forEach((group) => {
  306. group.actions.filter((action) => !isTerminalEdge(text(action.edge_id, ""))).forEach((action) => {
  307. const targetVideos = Array.isArray(action.target_videos) ? action.target_videos.filter(isRecord) : [];
  308. const children = targetVideos
  309. .map((video) => byId.get(text(video.id, "")))
  310. .filter((child): child is WalkGroup => child !== undefined && child.id !== group.id);
  311. if (!children.length) return;
  312. const uniqueChildren = [...new Map(children.map((child) => [child.id, child])).values()];
  313. childrenByActionId.set(actionKey(action), uniqueChildren);
  314. uniqueChildren.forEach((child) => childIds.add(child.id));
  315. });
  316. });
  317. const roots = groups.filter((group) => !childIds.has(group.id));
  318. return {
  319. roots: roots.length ? roots : groups,
  320. childrenByActionId,
  321. totalGroups: groups.length
  322. };
  323. }
  324. function buildWalkGroups(actions: RawRecord[]): WalkGroup[] {
  325. const groups = new Map<string, WalkGroup>();
  326. actions.forEach((action, index) => {
  327. const video = asRecord(action.source_video);
  328. const terminal = isTerminalEdge(text(action.edge_id, ""));
  329. const id = text(video.id) || `search-${text(action.from_label, "root")}-${index}`;
  330. if (!groups.has(id)) {
  331. const decision = asRecord(video.decision);
  332. groups.set(id, {
  333. id,
  334. kind: video.id ? "video" : "query",
  335. video: video.id ? video : undefined,
  336. title: video.id ? text(video.title, "无标题视频") : queryGroupTitle(action),
  337. author: video.id ? text(video.author, "未知作者") : "搜索词翻页",
  338. decisionLabel: text(decision.label, "未判断"),
  339. terminalLabel: terminal ? terminalResultText(action) : "",
  340. actions: []
  341. });
  342. }
  343. const group = groups.get(id)!;
  344. if (terminal && !group.terminalLabel) group.terminalLabel = terminalResultText(action);
  345. group.actions.push(action);
  346. });
  347. return [...groups.values()];
  348. }
  349. function actionKey(action: RawRecord): string {
  350. return text(action.id || action.walk_action_id || `${text(action.edge_id, "walk")}-${text(action.from_label, "")}-${text(action.result, "")}`);
  351. }
  352. function queryGroupTitle(action: RawRecord): string {
  353. const from = cleanBusinessText(text(action.from_label, ""));
  354. if (from) return from.replace(/^从搜索词/, "搜索词").replace(/继续$/, "");
  355. return "当前搜索词";
  356. }
  357. function sourceKicker(group: WalkGroup, index: number, level: number): string {
  358. if (level) return `第 ${level + 1} 层起点`;
  359. return group.kind === "video" ? `起点视频 ${index + 1}` : "搜索词翻页";
  360. }
  361. function querySourceName(value: string): string {
  362. return value.replace(/^搜索词《/, "").replace(/》$/, "") || "搜索词待确认";
  363. }
  364. function VideoLinks({ video, runId, compact = false }: { video?: RawRecord; runId: string; compact?: boolean }) {
  365. if (!video || !video.id) return null;
  366. const platformUrl = text(video.platform_url, "");
  367. return (
  368. <div className={compact ? "walk-video-links compact" : "walk-video-links"}>
  369. <Link href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(text(video.id, ""))}`}>
  370. 看详情
  371. </Link>
  372. {platformUrl ? (
  373. <a href={platformUrl} target="_blank" rel="noreferrer">
  374. <ExternalLink size={12} />
  375. 抖音
  376. </a>
  377. ) : null}
  378. </div>
  379. );
  380. }
  381. function actionDepth(action: RawRecord): number {
  382. const value = Number(action.depth || 0);
  383. return Number.isFinite(value) ? value : 0;
  384. }
  385. function splitTitleAndTags(value: string): { title: string; tags: string[] } {
  386. const matches = value.match(/#[^\s#]+/g) || [];
  387. const title = value.replace(/#[^\s#]+/g, " ").replace(/\s+/g, " ").trim();
  388. return {
  389. title: title || value,
  390. tags: matches.slice(0, 6)
  391. };
  392. }
  393. function scoreSummary(value: unknown): string {
  394. const items = Array.isArray(value) ? value.filter(isRecord) : [];
  395. const visible = items.filter((item) => text(item.score_label));
  396. if (!visible.length) return "";
  397. return visible.slice(0, 3).map((item) => `${shortScoreLabel(text(item.label, "评分"))} ${text(item.score_label)}`).join(" / ");
  398. }
  399. function actionScoreItems(action: RawRecord): { label: string; score: string }[] {
  400. const items = Array.isArray(action.score_items) ? action.score_items.filter(isRecord) : [];
  401. return items
  402. .map((item) => ({ label: text(item.label, "评分"), score: text(item.score_label, "") }))
  403. .filter((item) => item.score);
  404. }
  405. function videoScoreItems(video: RawRecord): { label: string; score: string }[] {
  406. const decision = asRecord(video.decision);
  407. const items = Array.isArray(decision.score_items) ? decision.score_items.filter(isRecord) : [];
  408. return items
  409. .map((item) => ({ label: text(item.label, "评分"), score: text(item.score_label, "") }))
  410. .filter((item) => item.score);
  411. }
  412. function gateText(action: RawRecord, isSuccess: boolean): string {
  413. const score = firstScore(scoreSummary(action.score_items));
  414. if (isSuccess) {
  415. const edgeId = text(action.edge_id, "");
  416. const label = edgeId === "hashtag_to_query" || edgeId === "tag_query"
  417. ? "标签评分通过"
  418. : edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author"
  419. ? "作者评分通过"
  420. : edgeId === "query_next_page"
  421. ? "翻页通过"
  422. : "筛选通过";
  423. return score ? `${label}:${score}` : label;
  424. }
  425. const reason = cleanBusinessText(text(action.reason_label, ""));
  426. if (reason.includes("游走次数上限")) return "原因:达到游走次数上限,未继续扩展";
  427. if (reason.includes("复看")) return "原因:待复看,先不继续扩展";
  428. if (reason.includes("不适合继续向外扩展")) return `原因:${reason.replace(/^不适合继续向外扩展:?/, "") || "不适合继续向外扩展"},未继续扩展`;
  429. return `原因:${reason || "没有达到继续扩展要求"},未继续扩展`;
  430. }
  431. function scoreDrawer(action: RawRecord, routeName: string, isSuccess: boolean): BusinessDrawerState {
  432. const items = actionScoreItems(action);
  433. const total = items.find((item) => item.label === "总分")?.score || "";
  434. const query = items.find((item) => item.label === "是否契合需求")?.score || "";
  435. const platform = items.find((item) => item.label === "平台表现")?.score || "";
  436. const reason = cleanBusinessText(text(action.reason_label, ""));
  437. return {
  438. title: `${routeName}筛选口径`,
  439. sections: [
  440. {
  441. label: "这一步为什么能继续",
  442. value: isSuccess
  443. ? `${routeName}已通过筛选${total ? `,总分 ${total}` : ""}。系统认为这条路线值得继续带回视频。`
  444. : `这一步没有继续游走。${reason ? `原因:${reason}` : "原因:没有达到继续扩展要求。"}`,
  445. tone: isSuccess ? "good" : "warn"
  446. },
  447. {
  448. label: "评分怎么看",
  449. items: [
  450. total ? `总分:综合判断这条路线值不值得继续。当前 ${total}。` : "总分:本次没有记录总分。",
  451. query ? `是否契合需求:看内容方向和本次需求是否贴合。当前 ${query}。` : "是否契合需求:本次没有记录该分。",
  452. platform ? `平台表现:看点赞、评论、转发等平台反馈。当前 ${platform}。` : "平台表现:本次没有记录该分。"
  453. ],
  454. tone: "info"
  455. },
  456. {
  457. label: "对流程的影响",
  458. value: isSuccess ? "通过后会生成新搜索词或翻页,并把带回的视频继续送去判断。" : "未通过时,这条路线停止,不再继续带回新视频。",
  459. tone: "neutral"
  460. }
  461. ]
  462. };
  463. }
  464. function firstScore(value: string): string {
  465. return value.split("/")[0]?.trim() || value;
  466. }
  467. function stopLabel(label: string): string {
  468. if (label === "入池") return "本卡未继续";
  469. if (label === "待复看") return "待复看停止";
  470. if (label === "淘汰") return "淘汰停止";
  471. return "未继续";
  472. }
  473. function terminalResultText(action: RawRecord): string {
  474. const edgeId = text(action.edge_id, "");
  475. if (edgeId === "decision_to_asset") return "入池";
  476. if (edgeId === "budget_downgrade") return "待复看";
  477. if (edgeId === "path_stop") return "淘汰并停止";
  478. return cleanBusinessText(text(action.result, "已记录"));
  479. }
  480. function isTerminalEdge(edgeId: string): boolean {
  481. return edgeId === "decision_to_asset" || edgeId === "budget_downgrade" || edgeId === "path_stop";
  482. }
  483. function routeLabel(edgeId: string): string {
  484. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "从视频标签继续游走";
  485. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "从作者继续游走";
  486. if (edgeId === "query_next_page") return "翻页继续游走";
  487. return "继续游走";
  488. }
  489. function routeSubject(action: RawRecord): string {
  490. const edgeId = text(action.edge_id, "");
  491. const raw = cleanBusinessText(text(action.trigger_label, ""));
  492. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") {
  493. const value = raw.replace(/^候选标签:/, "").replace(/^标签搜索词:/, "#").replace(/^标签:/, "");
  494. return value || "标签待确认";
  495. }
  496. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") {
  497. return raw.replace(/^作者:/, "") || "作者待确认";
  498. }
  499. if (edgeId === "query_next_page") {
  500. return cleanBusinessText(text(action.from_label, "当前搜索词")).replace(/^从搜索词/, "").replace(/继续$/, "") || "当前搜索词";
  501. }
  502. return raw || "当前内容";
  503. }
  504. function routeSummary(action: RawRecord, targetQuery: RawRecord, targetVideos: RawRecord[], isSuccess: boolean): string {
  505. if (!isSuccess) return stoppedSentence(action);
  506. const edgeId = text(action.edge_id, "");
  507. const subject = routeSubject(action);
  508. if (edgeId === "query_next_page") {
  509. return `从搜索词 ${subject} 翻到下一页,带回 ${targetVideos.length} 条视频。`;
  510. }
  511. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") {
  512. const queryText = text(targetQuery.text, "");
  513. return queryText ? `标签 ${subject} 通过筛选,生成搜索词《${queryText}》,带回 ${targetVideos.length} 条视频。` : `标签 ${subject} 通过筛选,带回 ${targetVideos.length} 条视频。`;
  514. }
  515. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") {
  516. return `作者 ${subject} 通过筛选,继续查看作品,带回 ${targetVideos.length} 条视频。`;
  517. }
  518. return cleanBusinessText(text(action.result, "已执行"));
  519. }
  520. function stoppedSentence(action: RawRecord): string {
  521. return gateText(action, false);
  522. }
  523. function shortScoreLabel(label: string): string {
  524. if (label === "是否契合需求") return "需求";
  525. if (label === "平台表现") return "平台";
  526. return label;
  527. }
  528. function cleanBusinessText(value: string): string {
  529. if (!value || value === "系统原因待补充" || value === "这一步没有记录额外原因") return "";
  530. return value.replaceAll("“", "《").replaceAll("”", "》");
  531. }
  532. function decisionTone(value: string): string {
  533. if (value === "入池") return "good";
  534. if (value === "待复看") return "warn";
  535. if (value === "淘汰") return "bad";
  536. return "plain";
  537. }
  538. function edgeTone(edgeId: string): string {
  539. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "tag";
  540. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "author";
  541. if (edgeId === "query_next_page") return "page";
  542. return "stop";
  543. }
  544. function edgeIcon(edgeId: string) {
  545. if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return <Hash size={15} />;
  546. if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return <UserSearch size={15} />;
  547. return <Search size={15} />;
  548. }
  549. function isRecord(value: unknown): value is RawRecord {
  550. return typeof value === "object" && value !== null;
  551. }