浏览代码

feat(web-v3): 多平台内容卡 + 配置页游走护栏 tab

- ContentPostCard 按 platform 分派(platformLabel/contentUrl/embedPlayerUrl);非抖音降级'无嵌入播放器/打开原帖';删 douyinContentUrl 写死
- ConfigPage 新增'游走护栏(walk-policy)'tab:global+门控矩阵(三色)+edge_budgets+budget_tiers;PolicyValue 解包
- RulePacksTab 遍历全部规则包(多平台)+scorecard 弃用维度标注;WalkStrategyTab 绑定/目录 note 修正
- 实测 config 4 tab、门控矩阵 3allow/1低/5deny、抖音卡动态平台名

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sam Lee 1 月之前
父节点
当前提交
be50456dcc
共有 3 个文件被更改,包括 195 次插入53 次删除
  1. 5 0
      web/app/globals.css
  2. 164 34
      web/features/config/ConfigPage.tsx
  3. 26 19
      web/features/runs/RunDashboardPage.tsx

+ 5 - 0
web/app/globals.css

@@ -1579,3 +1579,8 @@ a {
 .walk-edge-row .muted {
   color: #9aa4b5;
 }
+
+/* 游走护栏门控矩阵单元格三色 */
+.config-table td.gate-allow { color: #1d6b3e; background: #e9f7ef; font-weight: 600; }
+.config-table td.gate-low { color: #9a6a0a; background: #fff6e5; font-weight: 600; }
+.config-table td.gate-deny { color: #a82626; background: #fdecec; font-weight: 600; }

+ 164 - 34
web/features/config/ConfigPage.tsx

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
 import {
   getConfigQueryPrompts,
   getConfigRulePacks,
+  getConfigWalkPolicy,
   getConfigWalkStrategy
 } from "@/lib/api/client";
 import type { ConfigFileResponse } from "@/lib/api/types";
@@ -14,16 +15,33 @@ import { fieldAnnotation } from "@/lib/config/fieldLabels";
 const TABS = [
   { id: "rule-packs", label: "规则包" },
   { id: "walk-strategy", label: "游走策略" },
+  { id: "walk-policy", label: "游走护栏(walk-policy)" },
   { id: "query-prompts", label: "Query Prompts" }
 ] as const;
 
 type TabId = (typeof TABS)[number]["id"];
 
+// walk_policy 的拍板值可能裸值,也可能带 {value, provenance, tbd} 留痕包裹。
+function unwrap(value: unknown): unknown {
+  if (value && typeof value === "object" && "value" in (value as Record<string, unknown>)) {
+    return (value as Record<string, unknown>).value;
+  }
+  return value;
+}
+
+function provenanceOf(value: unknown): string {
+  if (value && typeof value === "object" && "provenance" in (value as Record<string, unknown>)) {
+    return String((value as Record<string, unknown>).provenance || "");
+  }
+  return "";
+}
+
 function cell(value: unknown): string {
-  if (value == null || value === "") return "—";
-  if (typeof value === "boolean") return value ? "是" : "否";
-  if (typeof value === "object") return JSON.stringify(value, null, 0);
-  return String(value);
+  const v = unwrap(value);
+  if (v == null || v === "") return "—";
+  if (typeof v === "boolean") return v ? "是" : "否";
+  if (typeof v === "object") return JSON.stringify(v, null, 0);
+  return String(v);
 }
 
 function ConfigTable({
@@ -105,10 +123,6 @@ function asRecord(value: unknown): Record<string, unknown> {
 function RulePacksTab({ data }: { data: Record<string, unknown> }) {
   const dispatch = asRows(data.rule_pack_dispatch);
   const packs = asRows(data.rule_packs);
-  const contentPack = asRecord(
-    packs.find((pack) => pack.rule_pack_id === "douyin_content_discovery_rule_pack_v1") || packs[0]
-  );
-  const scorecard = asRecord(contentPack.scorecard);
   return (
     <>
       <ConfigTable
@@ -117,30 +131,40 @@ function RulePacksTab({ data }: { data: Record<string, unknown> }) {
         rows={dispatch}
         fields={["dispatch_id", "target_entity", "content_format", "runtime_stage", "dispatch_enabled", "rule_pack_id", "rule_pack_version", "notes"]}
       />
-      <ConfigTable
-        title={`Hard Gates(${String(contentPack.rule_pack_id || "Content 包")})`}
-        note="missing_content_portrait 的 KEEP_CONTENT_FOR_REVIEW/review 即 M3 画像止血;动作/严重度全部配置驱动,代码无业务硬编码。"
-        rows={asRows(contentPack.hard_gates)}
-        fields={["gate_id", "label", "when", "decision_action", "severity", "stop_scoring", "priority"]}
-      />
-      <ConfigTable
-        title="Scorecard 维度"
-        rows={asRows(scorecard.dimensions)}
-        fields={["key", "label", "max_score", "weight_percent", "runtime_status"]}
-      />
-      <ConfigTable
-        title="Scorecard 评分规则"
-        rows={asRows(scorecard.scoring_rules)}
-        fields={["scoring_rule_id", "dimension_key", "field_path", "operator", "expected_value", "score_value", "enabled"]}
-      />
-      <ConfigTable
-        title="阈值(Thresholds)"
-        rows={asRows(contentPack.thresholds)}
-        fields={["min_score", "max_score", "decision_action", "decision_reason_code", "effect_status", "enabled"]}
-      />
+      {/* 遍历全部规则包(天然支持多平台);每包独立展示门槛 / 评分 / 阈值。 */}
+      {packs.map((pack, i) => {
+        const p = asRecord(pack);
+        const scorecard = asRecord(p.scorecard);
+        const packId = String(p.rule_pack_id || `包 ${i + 1}`);
+        return (
+          <div key={packId}>
+            <ConfigTable
+              title={`Hard Gates(${packId})`}
+              note="动作 / 严重度全部配置驱动;V3 新增 not_fit_senior / low_confidence / judge_failed(Gemini 判定门槛)。"
+              rows={asRows(p.hard_gates)}
+              fields={["gate_id", "label", "when", "decision_action", "severity", "stop_scoring", "priority"]}
+            />
+            <ConfigTable
+              title="Scorecard 维度"
+              note="V3 实际打分 = relevance(60)+ platform_heat(40);runtime_status=deprecated 的旧 5 维已弃用,仅留档。"
+              rows={asRows(scorecard.dimensions)}
+              fields={["key", "label", "max_score", "weight_percent", "runtime_status"]}
+            />
+            <ConfigTable
+              title="Scorecard 评分规则"
+              rows={asRows(scorecard.scoring_rules)}
+              fields={["scoring_rule_id", "dimension_key", "field_path", "operator", "expected_value", "score_value", "enabled"]}
+            />
+            <ConfigTable
+              title="阈值(Thresholds)"
+              rows={asRows(p.thresholds)}
+              fields={["min_score", "max_score", "decision_action", "decision_reason_code", "effect_status", "enabled"]}
+            />
+          </div>
+        );
+      })}
       <ConfigTable
         title="效果状态映射(effect_status_mapping)"
-        note="含 M3 新增的 hard gate KEEP→pending 映射(map_keep_for_review_pending_hard_gate)。"
         rows={asRows(data.effect_status_mapping)}
         fields={["mapping_id", "decision_action", "reason_category", "is_hard_gate", "content_effect_status", "query_effect_status", "priority", "enabled", "notes"]}
       />
@@ -153,19 +177,122 @@ function RulePacksTab({ data }: { data: Record<string, unknown> }) {
   );
 }
 
+const GATE_ACTION_ROWS: Array<{ key: string; cn: string }> = [
+  { key: "ADD_TO_CONTENT_POOL", cn: "入池" },
+  { key: "KEEP_CONTENT_FOR_REVIEW", cn: "复看" },
+  { key: "REJECT_CONTENT", cn: "淘汰" }
+];
+
+function WalkPolicyTab({ data }: { data: Record<string, unknown> }) {
+  const global = asRecord(data.global);
+  const perm = asRecord(data.edge_permissions);
+  const budgets = asRows(data.edge_budgets);
+  const tiers = asRecord(data.budget_tiers);
+  const dedup = asRecord(data.dedup);
+
+  // 受控边集合(edge_permissions 三态行里出现的边,排除 _meta)
+  const controlledEdges = new Set<string>();
+  GATE_ACTION_ROWS.forEach(({ key }) => {
+    const row = asRecord(perm[key]);
+    Object.keys(row).forEach((edge) => {
+      if (!edge.startsWith("_") && edge !== "note") controlledEdges.add(edge);
+    });
+  });
+  const edgeCols = [...controlledEdges];
+
+  return (
+    <>
+      <section className="section">
+        <h3 className="section-title">全局护栏(global)</h3>
+        <FactGrid
+          rows={Object.entries(global).map(([k, v]) => {
+            const prov = provenanceOf(v);
+            return [
+              `${fieldAnnotation(k).label}(${k})`,
+              prov ? `${cell(v)} — ${prov}` : cell(v)
+            ];
+          })}
+        />
+      </section>
+
+      <section className="section">
+        <h3 className="section-title">判定门控矩阵(edge_permissions)</h3>
+        <p className="muted">
+          判定动作 → 各受控出边的放行结果。hashtag_to_query 随 video_to_hashtag 链放行;其余 6 条边为导航 / 系统边,无判定门控。
+        </p>
+        <div className="config-table-wrap">
+          <table className="config-table">
+            <thead>
+              <tr>
+                <th>判定动作</th>
+                {edgeCols.map((edge) => (
+                  <th key={edge}>{edge}</th>
+                ))}
+              </tr>
+            </thead>
+            <tbody>
+              {GATE_ACTION_ROWS.map(({ key, cn }) => {
+                const row = asRecord(perm[key]);
+                return (
+                  <tr key={key}>
+                    <td>
+                      {cn}
+                      <small>{key}</small>
+                    </td>
+                    {edgeCols.map((edge) => {
+                      const v = String(unwrap(row[edge]) || "—");
+                      const cls =
+                        v === "allow" ? "gate-allow" : v === "allow_low_budget" ? "gate-low" : v === "deny" ? "gate-deny" : "";
+                      const vCn =
+                        v === "allow" ? "放行" : v === "allow_low_budget" ? "低预算" : v === "deny" ? "拦截" : v;
+                      return (
+                        <td key={edge} className={cls}>
+                          {vCn}
+                        </td>
+                      );
+                    })}
+                  </tr>
+                );
+              })}
+            </tbody>
+          </table>
+        </div>
+      </section>
+
+      <ConfigTable
+        title="边预算(edge_budgets)"
+        note="对齐 v1 实际硬限;provenance 留拍板来源,理想值待 M7 标定。"
+        rows={budgets}
+        fields={["edge_id", "max_total_actions", "max_per_query", "max_works_per_author", "max_per_content", "max_pages", "max_tag_hops", "provenance"]}
+      />
+
+      <section className="section">
+        <h3 className="section-title">预算档位(budget_tiers)与去重(dedup)</h3>
+        <FactGrid
+          rows={[
+            ...Object.entries(tiers).map(([k, v]) => [`tier · ${k}`, cell(v)] as [string, string]),
+            ...Object.entries(dedup).map(([k, v]) => [`dedup · ${k}`, cell(v)] as [string, string])
+          ]}
+        />
+      </section>
+    </>
+  );
+}
+
 function WalkStrategyTab({ data }: { data: Record<string, unknown> }) {
   return (
     <>
       <ConfigTable
-        title="边→规则包归属(walk_rule_pack_binding)"
-        note="M4 增补 path_stop / decision_to_asset 后共 8 条;归属包写在 walk action 顶层 rule_pack_id,实际执行包写在 raw_payload.rule_pack_execution。"
+        title="边→规则包显式绑定(walk_rule_pack_binding)"
+        note="仅 decision_to_asset → 内容包(advisory);其余边的判定门控见「游走护栏」tab 的 edge_permissions。"
         rows={asRows(data.walk_rule_pack_binding)}
         fields={["binding_id", "edge_id", "target_entity", "rule_pack_id", "dispatch_policy", "required", "notes"]}
       />
       <ConfigTable
         title="游走边目录(walk_edge_catalog)"
+        note="共 10 条边;edge_type=navigate/reseed/stop/budget;门控与预算见「游走护栏」tab。"
         rows={asRows(data.walk_edge_catalog)}
-        fields={["edge_id", "edge_type", "priority", "can_loop", "notes"]}
+        fields={["edge_id", "edge_type", "from_node_type", "to_node_type", "priority", "can_loop", "notes"]}
       />
       <ConfigTable
         title="预算策略(walk_budget_policy)"
@@ -224,14 +351,16 @@ export function ConfigPage() {
     setLoading(true);
     setError(null);
     try {
-      const [rulePacks, walkStrategy, queryPrompts] = await Promise.all([
+      const [rulePacks, walkStrategy, walkPolicy, queryPrompts] = await Promise.all([
         getConfigRulePacks(),
         getConfigWalkStrategy(),
+        getConfigWalkPolicy(),
         getConfigQueryPrompts()
       ]);
       setResponses({
         "rule-packs": rulePacks,
         "walk-strategy": walkStrategy,
+        "walk-policy": walkPolicy,
         "query-prompts": queryPrompts
       });
     } catch (err) {
@@ -275,6 +404,7 @@ export function ConfigPage() {
         {error ? <div className="error-state">{error}</div> : null}
         {current && tab === "rule-packs" ? <RulePacksTab data={current.data} /> : null}
         {current && tab === "walk-strategy" ? <WalkStrategyTab data={current.data} /> : null}
+        {current && tab === "walk-policy" ? <WalkPolicyTab data={current.data} /> : null}
         {current && tab === "query-prompts" ? <QueryPromptsTab data={current.data} /> : null}
       </section>
     </AppShell>

+ 26 - 19
web/features/runs/RunDashboardPage.tsx

@@ -36,7 +36,7 @@ import type {
   TimelineResponse,
 } from "@/lib/api/types";
 import {compactValue, statusLabel } from "@/lib/status/status";
-import { platformLabel } from "@/lib/platform/content";
+import { contentUrl, embedPlayerUrl, platformLabel } from "@/lib/platform/content";
 
 type DashboardData = {
   dashboard: DashboardResponse;
@@ -383,14 +383,6 @@ function primaryQuerySource(item: Record<string, unknown>): Record<string, unkno
   };
 }
 
-function douyinContentUrl(item: Record<string, unknown>): string {
-  const direct = item.share_url || item.url || item.aweme_url;
-  if (direct) {
-    return String(direct);
-  }
-  return `https://www.douyin.com/video/${encodeURIComponent(String(item.platform_content_id || ""))}`;
-}
-
 function runtimeData(file: RuntimeFileResponse | null): Record<string, unknown> {
   if (!file?.data || typeof file.data !== "object") {
     return {};
@@ -668,8 +660,10 @@ function ContentPostCard({ item, index }: { item: Record<string, unknown>; index
   const [videoOn, setVideoOn] = useState(false);
   const querySource = primaryQuerySource(item);
   const contentId = String(item.platform_content_id || "");
-  // 抖音官方嵌入播放器(无 X-Frame-Options,可跨站 iframe);autoplay=1 因用户主动点击触发。
-  const playerUrl = `https://open.douyin.com/player/video?vid=${encodeURIComponent(contentId)}&autoplay=1`;
+  const platform = String(item.platform || "");
+  const pLabel = platformLabel(platform);
+  const playerUrl = embedPlayerUrl(item); // 仅抖音有官方嵌入播放器;其余平台 null
+  const origin = contentUrl(item);
   return (
     <div className="business-record-card">
       <button
@@ -680,8 +674,8 @@ function ContentPostCard({ item, index }: { item: Record<string, unknown>; index
       >
         {open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
         <span className="content-card-heading">
-          <span>平台内容 #{index + 1}</span>
-          <strong>{compactValue(item.title || item.description || "抖音视频")}</strong>
+          <span>{pLabel}内容 #{index + 1}</span>
+          <strong>{compactValue(item.title || item.description || `${pLabel}视频`)}</strong>
         </span>
       </button>
       {open ? (
@@ -689,7 +683,7 @@ function ContentPostCard({ item, index }: { item: Record<string, unknown>; index
           <div className="content-card-detail">
             <div className="content-meta-grid">
               <span>
-                抖音视频 ID
+                {pLabel}视频 ID
                 <strong>{compactValue(item.platform_content_id)}</strong>
               </span>
               <span>
@@ -705,24 +699,37 @@ function ContentPostCard({ item, index }: { item: Record<string, unknown>; index
                 <strong>{queryGenerationMethodLabel(querySource.search_query_generation_method)}</strong>
               </span>
               <span>
-                抖音作者
+                {pLabel}作者
                 <strong>{compactValue(item.author_display_name || item.platform_author_id)}</strong>
               </span>
             </div>
             <div className="business-action-row">
-              <a className="text-button" href={douyinContentUrl(item)} rel="noreferrer" target="_blank">
-                打开原帖
-              </a>
+              {origin ? (
+                <a className="text-button" href={origin} rel="noreferrer" target="_blank">
+                  打开原帖
+                </a>
+              ) : (
+                <span className="muted">该平台未提供原帖链接</span>
+              )}
             </div>
           </div>
           <div className="content-card-video">
             {!contentId ? (
               <div className="content-video-placeholder muted">无视频 ID</div>
+            ) : !playerUrl ? (
+              <div className="content-video-placeholder muted">
+                {pLabel}无公开嵌入播放器
+                {origin ? (
+                  <a className="text-button" href={origin} rel="noreferrer" target="_blank">
+                    打开原帖
+                  </a>
+                ) : null}
+              </div>
             ) : videoOn ? (
               <iframe
                 className="content-video-frame"
                 src={playerUrl}
-                title={`抖音视频 ${contentId}`}
+                title={`${pLabel}视频 ${contentId}`}
                 loading="lazy"
                 allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
                 allowFullScreen