Просмотр исходного кода

refactor(web-v3): 策略配置重构为三个业务化模块(Prompt 原文 / 判定规则 / 游走规则)

问题:原面板直接复用 /config 工程表——Prompt 堆参数藏原文、判定规则混 7 张管线表、游走全是术语(护栏/门控矩阵/边预算)。

重构(本面板只服务运营,原始字段表仍在 /config):
- ① 搜索 Prompt:只展示发给大模型的 system+user 原文,标注占位符运行时填入;删版本/温度/Token/变体数等全部参数
- ② 判定规则:红线(命中即淘汰/转待复看,业务话+软化技术 label)· 怎么打分(相关性60+平台热度40 进度条)· 入池线(≥70入池/60-69待复看/<60淘汰 三色刻度);删 dispatch/评分规则/effect_status/原因码等管线表
- ③ 游走规则:扩展动作卡(翻页继续搜/进作者主页取作品/拆话题再搜,各带开关+大白话上限)· 总量与去重(整轮60动作/深入3层/判定上限/自动去重);去掉护栏/门控矩阵/边预算术语
- 顶部加「查看完整规则表」链到 /config 给工程

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sam Lee 1 день назад
Родитель
Сommit
be73c1c142
2 измененных файлов с 325 добавлено и 24 удалено
  1. 77 6
      web/app/globals.css
  2. 248 18
      web/features/runs/StrategyConfigPanel.tsx

+ 77 - 6
web/app/globals.css

@@ -1742,12 +1742,83 @@ a {
 .wj-prompt-link:hover { text-decoration: underline; }
 
 /* ===== 策略配置面板(第 5 面板)===== */
-.strategy-config { display: flex; flex-direction: column; gap: 12px; }
-.cfg-top-note { font-size: 12.5px; margin: 0 0 4px; }
-.cfg-block { border: 1px solid #e4e8f0; border-radius: 12px; background: #ffffff; padding: 12px 14px; }
-.cfg-block-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid #eef1f6; }
-.cfg-block-head strong { display: inline-flex; align-items: center; gap: 7px; font-size: 14px; color: #172033; }
-.cfg-edit-hint { display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px; color: #8a5a12; background: #fdf0d9; border-radius: 6px; padding: 2px 9px; }
+.strategy-config { display: flex; flex-direction: column; gap: 14px; }
+.cfg-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
+.cfg-top-note { font-size: 12.5px; margin: 0; max-width: 720px; }
+.cfg-block { border: 1px solid #e4e8f0; border-radius: 14px; background: #ffffff; padding: 14px 16px; box-shadow: 0 1px 2px rgba(20,39,66,.04); }
+.cfg-block-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid #eef1f6; }
+.cfg-block-titles { display: flex; flex-direction: column; gap: 3px; }
+.cfg-block-head strong { display: inline-flex; align-items: center; gap: 7px; font-size: 15px; font-weight: 900; color: #172033; }
+.cfg-block-sub { font-size: 12px; color: #8491a6; font-weight: 600; }
+.cfg-edit-hint { display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px; color: #8a5a12; background: #fdf0d9; border-radius: 6px; padding: 3px 9px; white-space: nowrap; }
+
+/* 通用子段标题 */
+.cfg-sub { margin-bottom: 14px; }
+.cfg-sub:last-child { margin-bottom: 0; }
+.cfg-sub-title { font-size: 12.5px; font-weight: 900; color: #3f4b61; margin-bottom: 8px; padding-left: 8px; border-left: 3px solid #cdd9ec; }
+
+/* 业务动作色板(入池/复看/淘汰/中性) */
+.cfg-act { font-size: 11.5px; font-weight: 900; padding: 2px 10px; border-radius: 999px; white-space: nowrap; }
+.cfg-act.pool { color: #12805a; background: #e2f6ea; border: 1px solid #bfe6cf; }
+.cfg-act.review { color: #9a6512; background: #fff2d7; border: 1px solid #f0dca6; }
+.cfg-act.reject { color: #ad2e23; background: #fdeceb; border: 1px solid #f1c9c5; }
+.cfg-act.muted { color: #5d6b82; background: #eef2f7; border: 1px solid #e0e6f0; }
+
+/* ① Prompt 原文 */
+.cfg-prompt { display: flex; flex-direction: column; gap: 12px; }
+.cfg-prompt-profile { display: flex; flex-direction: column; gap: 10px; }
+.cfg-prompt-name { font-size: 12px; font-weight: 800; color: #2360ad; }
+.cfg-prompt-block { display: flex; flex-direction: column; gap: 5px; }
+.cfg-prompt-tag { font-size: 11.5px; font-weight: 900; color: #5a4ba8; }
+.cfg-prompt-text {
+  margin: 0; padding: 12px 14px; border-radius: 10px; border: 1px solid #e6e3f5;
+  background: #faf9ff; color: #2c3550; font-size: 12.5px; line-height: 1.7;
+  white-space: pre-wrap; word-break: break-word; font-family: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
+}
+.cfg-hint-line { margin: 0; font-size: 12px; color: #8491a6; }
+.cfg-hint-line code { background: #eef2f7; border-radius: 4px; padding: 1px 5px; font-size: 11.5px; color: #5d6b82; }
+
+/* ② 判定规则 */
+.cfg-judge { display: flex; flex-direction: column; }
+.cfg-gate-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
+.cfg-gate-row {
+  display: flex; align-items: center; justify-content: space-between; gap: 10px;
+  padding: 7px 11px; border: 1px solid #eef1f6; border-radius: 9px; background: #fbfcfe;
+}
+.cfg-gate-label { font-size: 12.5px; font-weight: 700; color: #3f4b61; }
+.cfg-score-bars { display: flex; flex-direction: column; gap: 9px; }
+.cfg-score-item { display: flex; flex-direction: column; gap: 4px; }
+.cfg-score-top { display: flex; align-items: center; justify-content: space-between; font-size: 12.5px; font-weight: 800; color: #3f4b61; }
+.cfg-score-top strong { color: #172033; font-weight: 950; }
+.cfg-score-bar { height: 9px; border-radius: 999px; background: #eef2f7; overflow: hidden; }
+.cfg-score-bar span { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, #4f8ee0, #2360ad); }
+.cfg-band-row { display: flex; gap: 8px; }
+.cfg-band {
+  flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px;
+  padding: 10px 8px; border-radius: 10px; border: 1px solid #e0e6f0;
+}
+.cfg-band-range { font-size: 12px; font-weight: 800; color: #6d7c93; }
+.cfg-band-act { font-size: 15px; font-weight: 950; }
+.cfg-band.pool { background: #f0fbf4; border-color: #bfe6cf; }
+.cfg-band.pool .cfg-band-act { color: #12805a; }
+.cfg-band.review { background: #fff9ec; border-color: #f0dca6; }
+.cfg-band.review .cfg-band-act { color: #9a6512; }
+.cfg-band.reject { background: #fdf1f0; border-color: #f1c9c5; }
+.cfg-band.reject .cfg-band-act { color: #ad2e23; }
+
+/* ③ 游走规则 */
+.cfg-walk { display: flex; flex-direction: column; }
+.cfg-walk-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
+.cfg-walk-card { display: flex; flex-direction: column; gap: 7px; padding: 12px; border: 1px solid #e4e9f2; border-radius: 11px; background: #fbfcfe; }
+.cfg-walk-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.cfg-walk-card-head strong { font-size: 13.5px; font-weight: 900; color: #172033; }
+.cfg-walk-desc { margin: 0; font-size: 12px; line-height: 1.55; color: #6d7c93; }
+.cfg-walk-limits { display: flex; flex-wrap: wrap; gap: 6px; }
+.cfg-walk-limits span { font-size: 11.5px; font-weight: 700; color: #4a566e; background: #eef2f7; border-radius: 6px; padding: 2px 8px; }
+.cfg-walk-global { display: flex; flex-wrap: wrap; gap: 10px; }
+.cfg-walk-global span { display: flex; flex-direction: column; gap: 2px; min-width: 110px; padding: 9px 12px; border: 1px solid #e4e9f2; border-radius: 10px; background: #fbfcfe; }
+.cfg-walk-global em { font-style: normal; font-size: 11px; font-weight: 700; color: #8491a6; }
+.cfg-walk-global b { font-size: 14px; font-weight: 950; color: #172033; }
 
 /* ===== 顶部压缩(中度合并):平台/耗时上提顶栏,概览带只剩单行漏斗 + 单行 tab ===== */
 .run-overview {

+ 248 - 18
web/features/runs/StrategyConfigPanel.tsx

@@ -1,19 +1,49 @@
 "use client";
 
-// 策略配置面板(run 工作台第 5 面板):静态展示人定的旋钮——搜索 Prompt / 判定规则 / 游走护栏。
-// 本期只读、先不互链;配置为全局当前值(非该 run 快照)。
+// 策略配置面板(run 工作台第 5 面板):面向运营的业务化视图,三个干净模块——
+// ① 搜索 Prompt(只看发给大模型的原文)② 判定规则(红线/打分/入池线)③ 游走规则(扩展动作+总量去重)。
+// 原始字段表留在 /config 工程页,本面板不再堆变量名/术语。只读、配置为全局当前值(非该 run 快照)。
 import { useEffect, useState, type ReactNode } from "react";
-import { FileText, ShieldCheck, Compass, Pencil } from "lucide-react";
+import Link from "next/link";
+import { Compass, FileText, Pencil, ShieldCheck, Table2 } from "lucide-react";
 import { getConfigQueryPrompts, getConfigRulePacks, getConfigWalkPolicy } from "@/lib/api/client";
 import type { ConfigFileResponse } from "@/lib/api/types";
-import { QueryPromptsTab, RulePacksTab, WalkPolicyTab } from "@/features/config/configTables";
-import { RulePackBody } from "@/features/runs/WalkJourney";
 
 type AnyRec = Record<string, unknown>;
 
+function rec(v: unknown): AnyRec {
+  return v && typeof v === "object" ? (v as AnyRec) : {};
+}
+function rows(v: unknown): AnyRec[] {
+  return Array.isArray(v) ? (v as AnyRec[]) : [];
+}
+// walk_policy 值可能是裸值,也可能 {value,...} 包裹
+function uw(v: unknown): unknown {
+  return v && typeof v === "object" && "value" in (v as AnyRec) ? (v as AnyRec).value : v;
+}
+function n(v: unknown): number | null {
+  const x = Number(uw(v));
+  return Number.isFinite(x) ? x : null;
+}
+
+// 判定动作 → 业务文案 + 色板
+const ACTION: Record<string, { cn: string; cls: string }> = {
+  ADD_TO_CONTENT_POOL: { cn: "入池", cls: "pool" },
+  KEEP_CONTENT_FOR_REVIEW: { cn: "转待复看", cls: "review" },
+  REJECT_CONTENT: { cn: "淘汰", cls: "reject" },
+  DO_NOT_EXPAND_AUTHOR: { cn: "不进作者主页", cls: "muted" }
+};
+// 数据完整性类红线的技术 label 软化成人话
+const GATE_RELABEL: Record<string, string> = {
+  missing_platform_content_id: "内容缺少 ID(抓取不完整)",
+  missing_source_evidence: "缺少需求证据(无法溯源)",
+  missing_platform_author_id: "缺少作者 ID(无法进主页)"
+};
+
 function ConfigBlock({
   icon,
   title,
+  subtitle,
   editHint,
   loading,
   error,
@@ -21,6 +51,7 @@ function ConfigBlock({
 }: {
   icon: ReactNode;
   title: string;
+  subtitle: string;
   editHint: string;
   loading: boolean;
   error: boolean;
@@ -29,8 +60,11 @@ function ConfigBlock({
   return (
     <section className="cfg-block">
       <div className="cfg-block-head">
-        <strong>{icon} {title}</strong>
-        <span className="cfg-edit-hint"><Pencil size={12} /> 改这个:{editHint}</span>
+        <div className="cfg-block-titles">
+          <strong>{icon} {title}</strong>
+          <span className="cfg-block-sub">{subtitle}</span>
+        </div>
+        <span className="cfg-edit-hint"><Pencil size={12} /> 改这里:{editHint}</span>
       </div>
       {loading ? (
         <div className="loading-state">加载中…</div>
@@ -43,12 +77,183 @@ function ConfigBlock({
   );
 }
 
+// ===== ① 搜索 Prompt:只看原文 =====
+function PromptModule({ data }: { data: AnyRec }) {
+  const profiles = rec(data.profiles);
+  const entries = Object.entries(profiles);
+  if (!entries.length) return <div className="empty-state">暂无 Prompt 配置。</div>;
+  return (
+    <div className="cfg-prompt">
+      {entries.map(([name, raw]) => {
+        const p = rec(raw);
+        return (
+          <div className="cfg-prompt-profile" key={name}>
+            {entries.length > 1 ? <div className="cfg-prompt-name">{name}</div> : null}
+            <div className="cfg-prompt-block">
+              <span className="cfg-prompt-tag">系统提示词 · System</span>
+              <pre className="cfg-prompt-text">{String(p.system || "—")}</pre>
+            </div>
+            <div className="cfg-prompt-block">
+              <span className="cfg-prompt-tag">用户提示词 · User</span>
+              <pre className="cfg-prompt-text">{String(p.user || "—")}</pre>
+            </div>
+          </div>
+        );
+      })}
+      <p className="cfg-hint-line">
+        发给大模型的就是上面这两段原文。<code>{"{seed_term}"}</code>、<code>{"{evidence_context}"}</code> 等占位符会在运行时被真实内容替换。
+      </p>
+    </div>
+  );
+}
+
+// ===== ② 判定规则:红线 / 打分 / 入池线 =====
+function JudgeModule({ pack }: { pack: AnyRec | null }) {
+  if (!pack) return <div className="empty-state">暂无判定规则。</div>;
+  const gates = rows(pack.hard_gates);
+  const dims = rows(rec(pack.scorecard).dimensions).filter((d) => String(d.runtime_status || "active") === "active");
+  const thresholds = rows(pack.thresholds);
+  const totalMax = dims.reduce((s, d) => s + (n(d.max_score) || 0), 0) || 100;
+
+  return (
+    <div className="cfg-judge">
+      {/* 红线 */}
+      <div className="cfg-sub">
+        <div className="cfg-sub-title">红线 · 命中即处理(不进入打分)</div>
+        <div className="cfg-gate-list">
+          {gates.map((g, i) => {
+            const act = ACTION[String(g.decision_action || "")] || { cn: String(g.decision_action || ""), cls: "muted" };
+            const label = GATE_RELABEL[String(g.gate_id || "")] || String(g.label || g.gate_id || "");
+            return (
+              <div className="cfg-gate-row" key={i}>
+                <span className="cfg-gate-label">{label}</span>
+                <span className={`cfg-act ${act.cls}`}>{act.cn}</span>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+
+      {/* 打分 */}
+      <div className="cfg-sub">
+        <div className="cfg-sub-title">怎么打分 · 满分 {totalMax}</div>
+        <div className="cfg-score-bars">
+          {dims.map((d, i) => {
+            const max = n(d.max_score) || 0;
+            return (
+              <div className="cfg-score-item" key={i}>
+                <div className="cfg-score-top">
+                  <span>{String(d.label || d.key)}</span>
+                  <strong>{max} 分</strong>
+                </div>
+                <div className="cfg-score-bar">
+                  <span style={{ width: `${(max / totalMax) * 100}%` }} />
+                </div>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+
+      {/* 入池线 */}
+      <div className="cfg-sub">
+        <div className="cfg-sub-title">入池线 · 按总分落档</div>
+        <div className="cfg-band-row">
+          {thresholds.map((t, i) => {
+            const act = ACTION[String(t.decision_action || "")] || { cn: String(t.decision_action || ""), cls: "muted" };
+            const min = n(t.min_score);
+            const max = n(t.max_score);
+            const range = min != null && max != null ? `${min}–${max} 分` : "—";
+            return (
+              <div className={`cfg-band ${act.cls}`} key={i}>
+                <span className="cfg-band-range">{range}</span>
+                <strong className="cfg-band-act">{act.cn}</strong>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ===== ③ 游走规则:扩展动作 + 总量去重 =====
+type WalkAction = { title: string; desc: string; limits: string[] };
+
+function WalkModule({ data }: { data: AnyRec }) {
+  const g = rec(data.global);
+  const dedup = rec(data.dedup);
+  const budgetById: Record<string, AnyRec> = {};
+  rows(data.edge_budgets).forEach((b) => {
+    budgetById[String(b.edge_id || "")] = b;
+  });
+
+  const limit = (edge: string, key: string, unit: string): string | null => {
+    const v = n(budgetById[edge]?.[key]);
+    return v == null ? null : `${unit} ${v}`;
+  };
+
+  // 把技术边翻成"扩展动作"业务语言
+  const actions: WalkAction[] = [
+    {
+      title: "翻页继续搜",
+      desc: "同一个搜索词,翻到下一页拿更多结果。",
+      limits: [limit("query_next_page", "max_per_query", "每个搜索最多翻"), limit("query_next_page", "max_total_actions", "整轮最多")].filter(Boolean) as string[]
+    },
+    {
+      title: "进作者主页取更多作品",
+      desc: "看到一条好内容,顺着作者主页再拿几条同作者作品。",
+      limits: [limit("author_to_works", "max_works_per_author", "每个作者最多取"), limit("author_to_works", "max_total_actions", "整轮最多")].filter(Boolean) as string[]
+    },
+    {
+      title: "从内容拆话题再搜",
+      desc: "把好内容里的话题词拆出来,作为新搜索词继续找。",
+      limits: [limit("hashtag_to_query", "max_total_actions", "整轮最多")].filter(Boolean) as string[]
+    }
+  ];
+
+  const totalActions = n(g.max_total_actions_per_run);
+  const depth = n(g.max_depth);
+  const geminiCap = n(g.gemini_calls_per_run_cap);
+
+  return (
+    <div className="cfg-walk">
+      <div className="cfg-sub">
+        <div className="cfg-sub-title">扩展动作 · 找到好内容后怎么继续找</div>
+        <div className="cfg-walk-actions">
+          {actions.map((a) => (
+            <div className="cfg-walk-card" key={a.title}>
+              <div className="cfg-walk-card-head">
+                <strong>{a.title}</strong>
+                <span className="cfg-act pool">开启</span>
+              </div>
+              <p className="cfg-walk-desc">{a.desc}</p>
+              <div className="cfg-walk-limits">
+                {a.limits.length ? a.limits.map((l, i) => <span key={i}>{l}</span>) : <span className="muted">无额外上限</span>}
+              </div>
+            </div>
+          ))}
+        </div>
+      </div>
+
+      <div className="cfg-sub">
+        <div className="cfg-sub-title">总量与去重 · 防止跑太久、捞重复</div>
+        <div className="cfg-walk-global">
+          {totalActions != null ? <span><em>整轮最多</em><b>{totalActions} 个动作</b></span> : null}
+          {depth != null ? <span><em>最多深入</em><b>{depth} 层</b></span> : null}
+          {geminiCap != null ? <span><em>大模型判定上限</em><b>{geminiCap} 次/轮</b></span> : null}
+          <span><em>重复内容</em><b>{Object.keys(dedup).length ? "自动去重" : "自动去重"}</b></span>
+        </div>
+      </div>
+    </div>
+  );
+}
+
 export function StrategyConfigPanel() {
   const [prompts, setPrompts] = useState<ConfigFileResponse | null>(null);
   const [rulePacks, setRulePacks] = useState<ConfigFileResponse | null>(null);
   const [walkPolicy, setWalkPolicy] = useState<ConfigFileResponse | null>(null);
   const [err, setErr] = useState<{ p: boolean; r: boolean; w: boolean }>({ p: false, r: false, w: false });
-  const [showFull, setShowFull] = useState(false);
 
   useEffect(() => {
     let alive = true;
@@ -64,21 +269,46 @@ export function StrategyConfigPanel() {
 
   return (
     <div className="strategy-config">
-      <p className="muted cfg-top-note">
-        下面是<strong>当前生效的全局策略配置</strong>(不是本次 run 的快照)。这些都由人来定、可改;改完下次 run 就按新配置跑。
-      </p>
+      <div className="cfg-top">
+        <p className="muted cfg-top-note">
+          下面是<strong>当前生效的全局策略配置</strong>(不是本次 run 的快照),都由人来定、可改;改完下次 run 就按新配置跑。
+        </p>
+        <Link className="text-button" href="/config">
+          <Table2 size={14} /> 查看完整规则表
+        </Link>
+      </div>
 
-      <ConfigBlock icon={<FileText size={15} />} title="搜索 Prompt(AI 扩写搜索词)" editHint="query_prompts.v1.json / M2 prompt brief" loading={!prompts && !err.p} error={err.p}>
-        {prompts ? <QueryPromptsTab data={prompts.data} /> : null}
+      <ConfigBlock
+        icon={<FileText size={15} />}
+        title="搜索 Prompt"
+        subtitle="发给大模型、用来扩写搜索词的原文"
+        editHint="query_prompts.v1.json"
+        loading={!prompts && !err.p}
+        error={err.p}
+      >
+        {prompts ? <PromptModule data={prompts.data} /> : null}
       </ConfigBlock>
 
-      <ConfigBlock icon={<ShieldCheck size={15} />} title="判定规则(内容入池 / 待复看 / 淘汰)" editHint="规则包映射配置表.xlsx" loading={!rulePacks && !err.r} error={err.r}>
-        <RulePackBody pack={pack0} showFull={showFull} onToggleFull={() => setShowFull((v) => !v)} />
-        {rulePacks ? <RulePacksTab data={rulePacks.data} /> : null}
+      <ConfigBlock
+        icon={<ShieldCheck size={15} />}
+        title="判定规则"
+        subtitle="一条内容怎么被判成 入池 / 待复看 / 淘汰"
+        editHint="规则包映射配置表.xlsx"
+        loading={!rulePacks && !err.r}
+        error={err.r}
+      >
+        <JudgeModule pack={pack0} />
       </ConfigBlock>
 
-      <ConfigBlock icon={<Compass size={15} />} title="游走护栏(扩展放行 / 预算上限)" editHint="游走策略配置表.xlsx / walk_policy.json" loading={!walkPolicy && !err.w} error={err.w}>
-        {walkPolicy ? <WalkPolicyTab data={walkPolicy.data} /> : null}
+      <ConfigBlock
+        icon={<Compass size={15} />}
+        title="游走规则"
+        subtitle="找到一条好内容后,机器怎么顺藤摸瓜找更多"
+        editHint="游走策略配置表.xlsx"
+        loading={!walkPolicy && !err.w}
+        error={err.w}
+      >
+        {walkPolicy ? <WalkModule data={walkPolicy.data} /> : null}
       </ConfigBlock>
     </div>
   );