StrategyConfigPanel.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. "use client";
  2. // 策略配置面板(run 工作台第 5 面板):面向运营的业务化视图,三个干净模块——
  3. // ① 搜索 Prompt(只看发给大模型的原文)② 判定规则(红线/打分/入池线)③ 游走规则(扩展动作+总量去重)。
  4. // 原始字段表留在 /config 工程页,本面板不再堆变量名/术语。只读、配置为全局当前值(非该 run 快照)。
  5. import { useEffect, useState, type ReactNode } from "react";
  6. import Link from "next/link";
  7. import { Compass, FileText, Pencil, ShieldCheck, Table2 } from "lucide-react";
  8. import { getConfigQueryPrompts, getConfigRulePacks, getConfigWalkPolicy } from "@/lib/api/client";
  9. import type { ConfigFileResponse } from "@/lib/api/types";
  10. type AnyRec = Record<string, unknown>;
  11. function rec(v: unknown): AnyRec {
  12. return v && typeof v === "object" ? (v as AnyRec) : {};
  13. }
  14. function rows(v: unknown): AnyRec[] {
  15. return Array.isArray(v) ? (v as AnyRec[]) : [];
  16. }
  17. // walk_policy 值可能是裸值,也可能 {value,...} 包裹
  18. function uw(v: unknown): unknown {
  19. return v && typeof v === "object" && "value" in (v as AnyRec) ? (v as AnyRec).value : v;
  20. }
  21. function n(v: unknown): number | null {
  22. const x = Number(uw(v));
  23. return Number.isFinite(x) ? x : null;
  24. }
  25. // 判定动作 → 业务文案 + 色板
  26. const ACTION: Record<string, { cn: string; cls: string }> = {
  27. ADD_TO_CONTENT_POOL: { cn: "入池", cls: "pool" },
  28. KEEP_CONTENT_FOR_REVIEW: { cn: "转待复看", cls: "review" },
  29. REJECT_CONTENT: { cn: "淘汰", cls: "reject" },
  30. DO_NOT_EXPAND_AUTHOR: { cn: "不进作者主页", cls: "muted" }
  31. };
  32. // 数据完整性类红线的技术 label 软化成人话
  33. const GATE_RELABEL: Record<string, string> = {
  34. missing_platform_content_id: "内容缺少 ID(抓取不完整)",
  35. missing_source_evidence: "缺少需求证据(无法溯源)",
  36. missing_platform_author_id: "缺少作者 ID(无法进主页)"
  37. };
  38. function ConfigBlock({
  39. icon,
  40. title,
  41. subtitle,
  42. editHint,
  43. loading,
  44. error,
  45. children
  46. }: {
  47. icon: ReactNode;
  48. title: string;
  49. subtitle: string;
  50. editHint: string;
  51. loading: boolean;
  52. error: boolean;
  53. children: ReactNode;
  54. }) {
  55. return (
  56. <section className="cfg-block">
  57. <div className="cfg-block-head">
  58. <div className="cfg-block-titles">
  59. <strong>{icon} {title}</strong>
  60. <span className="cfg-block-sub">{subtitle}</span>
  61. </div>
  62. <span className="cfg-edit-hint"><Pencil size={12} /> 改这里:{editHint}</span>
  63. </div>
  64. {loading ? (
  65. <div className="loading-state">加载中…</div>
  66. ) : error ? (
  67. <div className="error-state">这块配置加载失败(其余不受影响)。</div>
  68. ) : (
  69. children
  70. )}
  71. </section>
  72. );
  73. }
  74. // ===== ① 搜索 Prompt:只看原文 =====
  75. function PromptModule({ data }: { data: AnyRec }) {
  76. const profiles = rec(data.profiles);
  77. const entries = Object.entries(profiles);
  78. if (!entries.length) return <div className="empty-state">暂无 Prompt 配置。</div>;
  79. return (
  80. <div className="cfg-prompt">
  81. {entries.map(([name, raw]) => {
  82. const p = rec(raw);
  83. return (
  84. <div className="cfg-prompt-profile" key={name}>
  85. {entries.length > 1 ? <div className="cfg-prompt-name">{name}</div> : null}
  86. <div className="cfg-prompt-block">
  87. <span className="cfg-prompt-tag">系统提示词 · System</span>
  88. <pre className="cfg-prompt-text">{String(p.system || "—")}</pre>
  89. </div>
  90. <div className="cfg-prompt-block">
  91. <span className="cfg-prompt-tag">用户提示词 · User</span>
  92. <pre className="cfg-prompt-text">{String(p.user || "—")}</pre>
  93. </div>
  94. </div>
  95. );
  96. })}
  97. <p className="cfg-hint-line">
  98. 发给大模型的就是上面这两段原文。<code>{"{seed_term}"}</code>、<code>{"{evidence_context}"}</code> 等占位符会在运行时被真实内容替换。
  99. </p>
  100. </div>
  101. );
  102. }
  103. // ===== ② 判定规则:红线 / 打分 / 入池线 =====
  104. function JudgeModule({ pack }: { pack: AnyRec | null }) {
  105. if (!pack) return <div className="empty-state">暂无判定规则。</div>;
  106. const gates = rows(pack.hard_gates);
  107. const dims = rows(rec(pack.scorecard).dimensions).filter((d) => String(d.runtime_status || "active") === "active");
  108. const thresholds = rows(pack.thresholds);
  109. const totalMax = dims.reduce((s, d) => s + (n(d.max_score) || 0), 0) || 100;
  110. return (
  111. <div className="cfg-judge">
  112. {/* 红线 */}
  113. <div className="cfg-sub">
  114. <div className="cfg-sub-title">红线 · 命中即处理(不进入打分)</div>
  115. <div className="cfg-gate-list">
  116. {gates.map((g, i) => {
  117. const act = ACTION[String(g.decision_action || "")] || { cn: String(g.decision_action || ""), cls: "muted" };
  118. const label = GATE_RELABEL[String(g.gate_id || "")] || String(g.label || g.gate_id || "");
  119. return (
  120. <div className="cfg-gate-row" key={i}>
  121. <span className="cfg-gate-label">{label}</span>
  122. <span className={`cfg-act ${act.cls}`}>{act.cn}</span>
  123. </div>
  124. );
  125. })}
  126. </div>
  127. </div>
  128. {/* 打分 */}
  129. <div className="cfg-sub">
  130. <div className="cfg-sub-title">怎么打分 · 满分 {totalMax}</div>
  131. <div className="cfg-score-bars">
  132. {dims.map((d, i) => {
  133. const max = n(d.max_score) || 0;
  134. return (
  135. <div className="cfg-score-item" key={i}>
  136. <div className="cfg-score-top">
  137. <span>{String(d.label || d.key)}</span>
  138. <strong>{max} 分</strong>
  139. </div>
  140. <div className="cfg-score-bar">
  141. <span style={{ width: `${(max / totalMax) * 100}%` }} />
  142. </div>
  143. </div>
  144. );
  145. })}
  146. </div>
  147. </div>
  148. {/* 入池线 */}
  149. <div className="cfg-sub">
  150. <div className="cfg-sub-title">入池线 · 按总分落档</div>
  151. <div className="cfg-band-row">
  152. {thresholds.map((t, i) => {
  153. const act = ACTION[String(t.decision_action || "")] || { cn: String(t.decision_action || ""), cls: "muted" };
  154. const min = n(t.min_score);
  155. const max = n(t.max_score);
  156. const range = min != null && max != null ? `${min}–${max} 分` : "—";
  157. return (
  158. <div className={`cfg-band ${act.cls}`} key={i}>
  159. <span className="cfg-band-range">{range}</span>
  160. <strong className="cfg-band-act">{act.cn}</strong>
  161. </div>
  162. );
  163. })}
  164. </div>
  165. </div>
  166. </div>
  167. );
  168. }
  169. // ===== ③ 游走规则:起点判断 → 三种动作 → 拆标签后接着走 + 刹车 =====
  170. type WalkAction = { title: string; desc: string; trigger: string; limits: string[] };
  171. // edge_permissions 的放行值 → 业务文案
  172. const PERM: Record<string, { cn: string; cls: string }> = {
  173. allow: { cn: "放行", cls: "pool" },
  174. allow_low_budget: { cn: "低预算放行", cls: "review" },
  175. deny: { cn: "不放行", cls: "reject" }
  176. };
  177. // 起点判断只看两种"发现型"游走(收入内容池属判定结果,不在此列)
  178. const WALK_EDGES: Array<{ edge: string; label: string }> = [
  179. { edge: "author_to_works", label: "进作者主页" },
  180. { edge: "video_to_hashtag", label: "拆标签再搜" }
  181. ];
  182. const VERDICTS: Array<{ key: string; cn: string; cls: string }> = [
  183. { key: "ADD_TO_CONTENT_POOL", cn: "入池", cls: "pool" },
  184. { key: "KEEP_CONTENT_FOR_REVIEW", cn: "待复看", cls: "review" },
  185. { key: "REJECT_CONTENT", cn: "淘汰", cls: "reject" }
  186. ];
  187. function WalkModule({ data }: { data: AnyRec }) {
  188. const g = rec(data.global);
  189. const perm = rec(data.edge_permissions);
  190. const budgetById: Record<string, AnyRec> = {};
  191. rows(data.edge_budgets).forEach((b) => {
  192. budgetById[String(b.edge_id || "")] = b;
  193. });
  194. const limit = (edge: string, key: string, unit: string): string | null => {
  195. const v = n(budgetById[edge]?.[key]);
  196. return v == null ? null : `${unit} ${v}`;
  197. };
  198. const actions: WalkAction[] = [
  199. {
  200. title: "翻页继续搜",
  201. desc: "同一个搜索词,翻到下一页拿更多结果。",
  202. trigger: "任何搜索都可翻页(与判定结果无关)",
  203. limits: [limit("query_next_page", "max_per_query", "每个搜索最多翻"), limit("query_next_page", "max_total_actions", "整轮最多")].filter(Boolean) as string[]
  204. },
  205. {
  206. title: "进作者主页取作品",
  207. desc: "顺着这条内容的作者主页,再拿几条同作者作品。",
  208. trigger: "入池→正常进 · 待复看→只低预算探 · 淘汰→不进",
  209. limits: [limit("author_to_works", "max_works_per_author", "每个作者最多取"), limit("author_to_works", "max_total_actions", "整轮最多")].filter(Boolean) as string[]
  210. },
  211. {
  212. title: "从内容拆标签再搜",
  213. desc: "把内容里的标签拆出来,作为新搜索词继续找。",
  214. trigger: "仅「入池」内容、且标签强相关才会拆",
  215. limits: [limit("hashtag_to_query", "max_total_actions", "整轮最多")].filter(Boolean) as string[]
  216. }
  217. ];
  218. const totalActions = n(g.max_total_actions_per_run);
  219. const depth = n(g.max_depth);
  220. const reseedRounds = n(g.max_reseed_rounds);
  221. const geminiCap = n(g.gemini_calls_per_run_cap);
  222. return (
  223. <div className="cfg-walk">
  224. {/* A. 起点:用判定结果决定能不能继续游走 */}
  225. <div className="cfg-sub">
  226. <div className="cfg-sub-title">从哪种内容开始游走 · 由判定结果决定</div>
  227. <p className="cfg-walk-note">
  228. 「好不好」不在这里另判,而是直接用上面②的判定结果:<strong>入池</strong>最优、放开游走;<strong>待复看</strong>只小范围探一探;<strong>淘汰</strong>到此为止。
  229. </p>
  230. <div className="cfg-perm-table">
  231. <div className="cfg-perm-head">
  232. <span>判定结果</span>
  233. {WALK_EDGES.map((e) => (
  234. <span key={e.edge}>{e.label}</span>
  235. ))}
  236. </div>
  237. {VERDICTS.map((v) => {
  238. const row = rec(perm[v.key]);
  239. return (
  240. <div className="cfg-perm-row" key={v.key}>
  241. <span className={`cfg-act ${v.cls}`}>{v.cn}</span>
  242. {WALK_EDGES.map((e) => {
  243. const p = PERM[String(uw(row[e.edge]) || "deny")] || PERM.deny;
  244. return (
  245. <span className={`cfg-perm-cell ${p.cls}`} key={e.edge}>
  246. {p.cn}
  247. </span>
  248. );
  249. })}
  250. </div>
  251. );
  252. })}
  253. </div>
  254. </div>
  255. {/* B. 三种游走动作 */}
  256. <div className="cfg-sub">
  257. <div className="cfg-sub-title">能往哪三个方向走</div>
  258. <div className="cfg-walk-actions">
  259. {actions.map((a) => (
  260. <div className="cfg-walk-card" key={a.title}>
  261. <div className="cfg-walk-card-head">
  262. <strong>{a.title}</strong>
  263. </div>
  264. <p className="cfg-walk-desc">{a.desc}</p>
  265. <div className="cfg-walk-trigger">触发:{a.trigger}</div>
  266. <div className="cfg-walk-limits">
  267. {a.limits.length ? a.limits.map((l, i) => <span key={i}>{l}</span>) : <span className="muted">无额外上限</span>}
  268. </div>
  269. </div>
  270. ))}
  271. </div>
  272. </div>
  273. {/* C. 拆标签之后接着往哪走 + 刹车 */}
  274. <div className="cfg-sub">
  275. <div className="cfg-sub-title">拆标签之后,怎么接着走</div>
  276. <div className="cfg-walk-chain">
  277. <span className="cfg-chain-step">入池内容拆出强相关标签</span>
  278. <span className="cfg-chain-arrow">→</span>
  279. <span className="cfg-chain-step">标签变成新的搜索词</span>
  280. <span className="cfg-chain-arrow">→</span>
  281. <span className="cfg-chain-step">这条新搜索重新走全流程:翻页 → 判定 → 对又判成入池的,再进作者主页 / 再拆标签</span>
  282. <span className="cfg-chain-arrow">↻</span>
  283. <span className="cfg-chain-step">如此层层深入,直到撞上下面的刹车</span>
  284. </div>
  285. <div className="cfg-walk-brakes">
  286. {depth != null ? <span>最多深入 <b>{depth}</b> 层</span> : null}
  287. {reseedRounds != null ? <span>拆标签再搜最多 <b>{reseedRounds}</b> 轮</span> : null}
  288. {totalActions != null ? <span>整轮最多 <b>{totalActions}</b> 个动作</span> : null}
  289. {geminiCap != null ? <span>大模型判定上限 <b>{geminiCap}</b> 次/轮</span> : null}
  290. <span>只有「入池」内容能当种子</span>
  291. <span>只有强相关标签才扩散</span>
  292. <span>重复内容自动去重</span>
  293. </div>
  294. </div>
  295. </div>
  296. );
  297. }
  298. export function StrategyConfigPanel() {
  299. const [prompts, setPrompts] = useState<ConfigFileResponse | null>(null);
  300. const [rulePacks, setRulePacks] = useState<ConfigFileResponse | null>(null);
  301. const [walkPolicy, setWalkPolicy] = useState<ConfigFileResponse | null>(null);
  302. const [err, setErr] = useState<{ p: boolean; r: boolean; w: boolean }>({ p: false, r: false, w: false });
  303. useEffect(() => {
  304. let alive = true;
  305. getConfigQueryPrompts().then((r) => alive && setPrompts(r)).catch(() => alive && setErr((e) => ({ ...e, p: true })));
  306. getConfigRulePacks().then((r) => alive && setRulePacks(r)).catch(() => alive && setErr((e) => ({ ...e, r: true })));
  307. getConfigWalkPolicy().then((r) => alive && setWalkPolicy(r)).catch(() => alive && setErr((e) => ({ ...e, w: true })));
  308. return () => {
  309. alive = false;
  310. };
  311. }, []);
  312. const pack0 = ((rulePacks?.data?.rule_packs as AnyRec[] | undefined) || [])[0] || null;
  313. return (
  314. <div className="strategy-config">
  315. <div className="cfg-top">
  316. <p className="muted cfg-top-note">
  317. 下面是<strong>当前生效的全局策略配置</strong>(不是本次 run 的快照),都由人来定、可改;改完下次 run 就按新配置跑。
  318. </p>
  319. <Link className="text-button" href="/config">
  320. <Table2 size={14} /> 查看完整规则表
  321. </Link>
  322. </div>
  323. <ConfigBlock
  324. icon={<FileText size={15} />}
  325. title="搜索 Prompt"
  326. subtitle="发给大模型、用来扩写搜索词的原文"
  327. editHint="query_prompts.v1.json"
  328. loading={!prompts && !err.p}
  329. error={err.p}
  330. >
  331. {prompts ? <PromptModule data={prompts.data} /> : null}
  332. </ConfigBlock>
  333. <ConfigBlock
  334. icon={<ShieldCheck size={15} />}
  335. title="判定规则"
  336. subtitle="一条内容怎么被判成 入池 / 待复看 / 淘汰"
  337. editHint="规则包映射配置表.xlsx"
  338. loading={!rulePacks && !err.r}
  339. error={err.r}
  340. >
  341. <JudgeModule pack={pack0} />
  342. </ConfigBlock>
  343. <ConfigBlock
  344. icon={<Compass size={15} />}
  345. title="游走规则"
  346. subtitle="找到一条好内容后,机器怎么顺藤摸瓜找更多"
  347. editHint="游走策略配置表.xlsx"
  348. loading={!walkPolicy && !err.w}
  349. error={err.w}
  350. >
  351. {walkPolicy ? <WalkModule data={walkPolicy.data} /> : null}
  352. </ConfigBlock>
  353. </div>
  354. );
  355. }