|
|
@@ -0,0 +1,319 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+import { useEffect, useMemo, useState } from "react";
|
|
|
+import { GitBranch } from "lucide-react";
|
|
|
+import { getConfigWalkPolicy, getConfigWalkStrategy, getRuntimeFile } from "@/lib/api/client";
|
|
|
+import type { WalkGraphEdge } from "@/lib/api/types";
|
|
|
+
|
|
|
+// 边的中文名(配置里 edge_label 是英文;此处给业务可读名)。
|
|
|
+const EDGE_LABEL_CN: Record<string, string> = {
|
|
|
+ query_next_page: "翻页搜索",
|
|
|
+ search_page_to_content: "搜索页拆视频",
|
|
|
+ video_to_author: "视频带出作者",
|
|
|
+ author_to_works: "作者拉作品页",
|
|
|
+ author_work_to_content: "作者作品回到判断",
|
|
|
+ video_to_hashtag: "视频拆话题",
|
|
|
+ hashtag_to_query: "话题生成新 query",
|
|
|
+ decision_to_asset: "判定落资产",
|
|
|
+ path_stop: "路径停止",
|
|
|
+ budget_downgrade: "预算降级"
|
|
|
+};
|
|
|
+
|
|
|
+const GATE_ACTIONS: Array<{ key: string; cn: string }> = [
|
|
|
+ { key: "ADD_TO_CONTENT_POOL", cn: "入池" },
|
|
|
+ { key: "KEEP_CONTENT_FOR_REVIEW", cn: "复看" },
|
|
|
+ { key: "REJECT_CONTENT", cn: "淘汰" }
|
|
|
+];
|
|
|
+
|
|
|
+const RULE_PACK_LABEL: Record<string, string> = {
|
|
|
+ douyin_content_discovery_rule_pack_v1: "内容发现规则包 V1"
|
|
|
+};
|
|
|
+
|
|
|
+type AnyRec = Record<string, unknown>;
|
|
|
+
|
|
|
+type PermVerdict = "allow" | "allow_low_budget" | "deny" | "n/a";
|
|
|
+
|
|
|
+type WalkEdgeEntry = {
|
|
|
+ edgeId: string;
|
|
|
+ labelCn: string;
|
|
|
+ edgeType: string;
|
|
|
+ fromNode: string;
|
|
|
+ toNode: string;
|
|
|
+ canLoop: boolean;
|
|
|
+ runtimeStage: string;
|
|
|
+ notes: string;
|
|
|
+ binding: { rulePackId: string; dispatchPolicy: string; required: boolean } | null;
|
|
|
+ permissions: Record<string, PermVerdict> | null; // action -> verdict;null=该边无判定门控
|
|
|
+ permissionSource: "explicit" | "follows_video_to_hashtag" | null;
|
|
|
+ budget: AnyRec | null;
|
|
|
+ execution: {
|
|
|
+ actionCount: number;
|
|
|
+ statusCounts: Record<string, number>;
|
|
|
+ reasonCodes: string[];
|
|
|
+ rulePackIds: string[];
|
|
|
+ actions: AnyRec[];
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+// 解包 walk_policy 的 {value,provenance,tbd} 留痕形态。
|
|
|
+function unwrap(v: unknown): string {
|
|
|
+ if (v && typeof v === "object" && "value" in (v as AnyRec)) {
|
|
|
+ return String((v as AnyRec).value);
|
|
|
+ }
|
|
|
+ return String(v);
|
|
|
+}
|
|
|
+
|
|
|
+function buildEntries(
|
|
|
+ catalog: AnyRec[],
|
|
|
+ binding: AnyRec[],
|
|
|
+ policy: AnyRec | null,
|
|
|
+ actions: AnyRec[]
|
|
|
+): WalkEdgeEntry[] {
|
|
|
+ const bindingByEdge = new Map<string, AnyRec>();
|
|
|
+ binding.forEach((b) => bindingByEdge.set(String(b.edge_id), b));
|
|
|
+
|
|
|
+ const permTable = (policy?.edge_permissions as AnyRec) || {};
|
|
|
+ const budgetByEdge = new Map<string, AnyRec>();
|
|
|
+ ((policy?.edge_budgets as AnyRec[]) || []).forEach((b) => budgetByEdge.set(String(b.edge_id), b));
|
|
|
+
|
|
|
+ // 哪些边在 edge_permissions 里被显式控制
|
|
|
+ const controlledEdges = new Set<string>();
|
|
|
+ GATE_ACTIONS.forEach(({ key }) => {
|
|
|
+ const row = permTable[key];
|
|
|
+ if (row && typeof row === "object") {
|
|
|
+ Object.keys(row as AnyRec).forEach((edgeId) => {
|
|
|
+ if (!edgeId.startsWith("_") && edgeId !== "note") controlledEdges.add(edgeId);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 执行聚合:按 edge_id 分组
|
|
|
+ const actionsByEdge = new Map<string, AnyRec[]>();
|
|
|
+ actions.forEach((a) => {
|
|
|
+ const eid = String(a.edge_id || "");
|
|
|
+ if (!eid) return;
|
|
|
+ if (!actionsByEdge.has(eid)) actionsByEdge.set(eid, []);
|
|
|
+ actionsByEdge.get(eid)!.push(a);
|
|
|
+ });
|
|
|
+
|
|
|
+ return catalog.map((e) => {
|
|
|
+ const edgeId = String(e.edge_id);
|
|
|
+ const bind = bindingByEdge.get(edgeId);
|
|
|
+ const acts = actionsByEdge.get(edgeId) || [];
|
|
|
+
|
|
|
+ let permissions: Record<string, PermVerdict> | null = null;
|
|
|
+ let permissionSource: WalkEdgeEntry["permissionSource"] = null;
|
|
|
+ if (controlledEdges.has(edgeId)) {
|
|
|
+ permissions = {};
|
|
|
+ GATE_ACTIONS.forEach(({ key }) => {
|
|
|
+ const row = (permTable[key] as AnyRec) || {};
|
|
|
+ permissions![key] = (unwrap(row[edgeId]) || "deny") as PermVerdict;
|
|
|
+ });
|
|
|
+ permissionSource = "explicit";
|
|
|
+ } else if (edgeId === "hashtag_to_query") {
|
|
|
+ permissionSource = "follows_video_to_hashtag";
|
|
|
+ }
|
|
|
+
|
|
|
+ const statusCounts: Record<string, number> = {};
|
|
|
+ const reasonCodes = new Set<string>();
|
|
|
+ const rulePackIds = new Set<string>();
|
|
|
+ acts.forEach((a) => {
|
|
|
+ const st = String(a.walk_status || "unknown");
|
|
|
+ statusCounts[st] = (statusCounts[st] || 0) + 1;
|
|
|
+ if (a.reason_code) reasonCodes.add(String(a.reason_code));
|
|
|
+ if (a.rule_pack_id) rulePackIds.add(String(a.rule_pack_id));
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ edgeId,
|
|
|
+ labelCn: EDGE_LABEL_CN[edgeId] || String(e.edge_label || edgeId),
|
|
|
+ edgeType: String(e.edge_type || ""),
|
|
|
+ fromNode: String(e.from_node_type || ""),
|
|
|
+ toNode: String(e.to_node_type || ""),
|
|
|
+ canLoop: Boolean(e.can_loop),
|
|
|
+ runtimeStage: String(e.runtime_stage || ""),
|
|
|
+ notes: String(e.notes || ""),
|
|
|
+ binding: bind
|
|
|
+ ? {
|
|
|
+ rulePackId: String(bind.rule_pack_id),
|
|
|
+ dispatchPolicy: String(bind.dispatch_policy || ""),
|
|
|
+ required: Boolean(bind.required)
|
|
|
+ }
|
|
|
+ : null,
|
|
|
+ permissions,
|
|
|
+ permissionSource,
|
|
|
+ budget: budgetByEdge.get(edgeId) || null,
|
|
|
+ execution: {
|
|
|
+ actionCount: acts.length,
|
|
|
+ statusCounts,
|
|
|
+ reasonCodes: [...reasonCodes],
|
|
|
+ rulePackIds: [...rulePackIds],
|
|
|
+ actions: acts
|
|
|
+ }
|
|
|
+ };
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function execBadge(e: WalkEdgeEntry): { text: string; cls: string } {
|
|
|
+ const { actionCount, statusCounts, reasonCodes } = e.execution;
|
|
|
+ if (actionCount === 0) return { text: "本次未触发", cls: "muted" };
|
|
|
+ const blocked = (statusCounts.rule_blocked || 0) + (statusCounts.failed || 0);
|
|
|
+ if (blocked > 0 || reasonCodes.length) {
|
|
|
+ return { text: `执行 ${actionCount} 次 · 含拦截/降级`, cls: "warn" };
|
|
|
+ }
|
|
|
+ return { text: `执行 ${actionCount} 次`, cls: "ok" };
|
|
|
+}
|
|
|
+
|
|
|
+function budgetText(b: AnyRec): string {
|
|
|
+ const parts: string[] = [];
|
|
|
+ const fields: Array<[string, string]> = [
|
|
|
+ ["max_total_actions", "单 run"],
|
|
|
+ ["max_per_query", "每 query"],
|
|
|
+ ["max_works_per_author", "每作者作品"],
|
|
|
+ ["max_per_content", "每内容"],
|
|
|
+ ["max_pages", "页数"],
|
|
|
+ ["max_tag_hops", "tag 跳数"]
|
|
|
+ ];
|
|
|
+ fields.forEach(([k, cn]) => {
|
|
|
+ if (b[k] !== undefined && b[k] !== null) parts.push(`${cn} ≤ ${unwrap(b[k])}`);
|
|
|
+ });
|
|
|
+ return parts.join(" · ") || "无显式预算";
|
|
|
+}
|
|
|
+
|
|
|
+export function WalkEdgeList({
|
|
|
+ runId,
|
|
|
+ fallbackEdges,
|
|
|
+ onOpen
|
|
|
+}: {
|
|
|
+ runId: string;
|
|
|
+ fallbackEdges: WalkGraphEdge[];
|
|
|
+ onOpen: (payload: Record<string, unknown>) => void;
|
|
|
+}) {
|
|
|
+ const [catalog, setCatalog] = useState<AnyRec[] | null>(null);
|
|
|
+ const [binding, setBinding] = useState<AnyRec[]>([]);
|
|
|
+ const [policy, setPolicy] = useState<AnyRec | null>(null);
|
|
|
+ const [actions, setActions] = useState<AnyRec[] | null>(null);
|
|
|
+ const [configError, setConfigError] = useState(false);
|
|
|
+ const [policyError, setPolicyError] = useState(false);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ let alive = true;
|
|
|
+ getConfigWalkStrategy()
|
|
|
+ .then((r) => {
|
|
|
+ if (!alive) return;
|
|
|
+ setCatalog((r.data.walk_edge_catalog as AnyRec[]) || []);
|
|
|
+ setBinding((r.data.walk_rule_pack_binding as AnyRec[]) || []);
|
|
|
+ })
|
|
|
+ .catch(() => alive && setConfigError(true));
|
|
|
+ getConfigWalkPolicy()
|
|
|
+ .then((r) => alive && setPolicy(r.data))
|
|
|
+ .catch(() => alive && setPolicyError(true));
|
|
|
+ getRuntimeFile(runId, "walk_actions.jsonl", 500)
|
|
|
+ .then((r) => alive && setActions(r.records || []))
|
|
|
+ .catch(() => {
|
|
|
+ // 兜底:walk_actions 缺失时,从 dashboard 合成边按 edge_id(=label)聚合。
|
|
|
+ if (!alive) return;
|
|
|
+ const synthetic = fallbackEdges
|
|
|
+ .filter((e) => e.label && EDGE_LABEL_CN[String(e.label)])
|
|
|
+ .map((e) => ({
|
|
|
+ edge_id: e.label,
|
|
|
+ walk_status: e.status,
|
|
|
+ reason_code: e.reason_code,
|
|
|
+ rule_pack_id: e.rule_pack
|
|
|
+ }));
|
|
|
+ setActions(synthetic);
|
|
|
+ });
|
|
|
+ return () => {
|
|
|
+ alive = false;
|
|
|
+ };
|
|
|
+ }, [runId, fallbackEdges]);
|
|
|
+
|
|
|
+ const entries = useMemo(() => {
|
|
|
+ if (!catalog) return null;
|
|
|
+ return buildEntries(catalog, binding, policy, actions || []);
|
|
|
+ }, [catalog, binding, policy, actions]);
|
|
|
+
|
|
|
+ if (configError) {
|
|
|
+ return (
|
|
|
+ <div className="walk-empty-board">
|
|
|
+ <GitBranch size={28} />
|
|
|
+ <strong>游走边目录加载失败</strong>
|
|
|
+ <span>无法读取 /config/walk-strategy;请确认后端配置端点可用。</span>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (!entries) {
|
|
|
+ return <div className="empty-state">正在加载游走边目录…</div>;
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="walk-edge-list">
|
|
|
+ <div className="walk-edge-hint">
|
|
|
+ 全部 {entries.length} 条游走边目录;每条边的规则包绑定与判定门控来自配置,执行情况来自本次 run。
|
|
|
+ {policyError ? "(门控配置 /config/walk-policy 不可用,门控行降级显示)" : ""}
|
|
|
+ </div>
|
|
|
+ {entries.map((e) => {
|
|
|
+ const badge = execBadge(e);
|
|
|
+ return (
|
|
|
+ <button type="button" className="walk-edge-card" key={e.edgeId} onClick={() => onOpen(e as unknown as Record<string, unknown>)}>
|
|
|
+ {/* 行1:边本身 */}
|
|
|
+ <div className="walk-edge-row walk-edge-row-head">
|
|
|
+ <strong>{e.labelCn}</strong>
|
|
|
+ <code>{e.edgeId}</code>
|
|
|
+ <span className="walk-edge-flow">
|
|
|
+ {e.fromNode} → {e.toNode}
|
|
|
+ </span>
|
|
|
+ {e.canLoop ? <span className="walk-edge-tag">可循环</span> : null}
|
|
|
+ <span className={`walk-edge-exec ${badge.cls}`}>{badge.text}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 行2:规则包 */}
|
|
|
+ <div className="walk-edge-row">
|
|
|
+ <span className="walk-edge-rowlabel">规则包</span>
|
|
|
+ {e.binding ? (
|
|
|
+ <span>
|
|
|
+ {RULE_PACK_LABEL[e.binding.rulePackId] || e.binding.rulePackId} · {e.binding.dispatchPolicy} 绑定
|
|
|
+ {e.binding.required ? "(必须)" : ""}
|
|
|
+ </span>
|
|
|
+ ) : (
|
|
|
+ <span className="muted">无规则包(导航边 / 系统边)</span>
|
|
|
+ )}
|
|
|
+ {e.execution.rulePackIds
|
|
|
+ .filter((id) => id !== e.binding?.rulePackId)
|
|
|
+ .map((id) => (
|
|
|
+ <span className="walk-edge-tag" key={id} title="本次 run 实际执行包">
|
|
|
+ 执行包:{RULE_PACK_LABEL[id] || id}
|
|
|
+ </span>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 行3:门控矩阵 + 预算 */}
|
|
|
+ <div className="walk-edge-row">
|
|
|
+ <span className="walk-edge-rowlabel">判定门控</span>
|
|
|
+ {e.permissions ? (
|
|
|
+ GATE_ACTIONS.map(({ key, cn }) => {
|
|
|
+ const v = e.permissions![key];
|
|
|
+ const cls =
|
|
|
+ v === "allow" ? "ok" : v === "allow_low_budget" ? "warn" : "deny";
|
|
|
+ const vCn = v === "allow" ? "放行" : v === "allow_low_budget" ? "低预算" : "拦截";
|
|
|
+ return (
|
|
|
+ <span className={`walk-edge-gate-chip ${cls}`} key={key}>
|
|
|
+ {cn} → {vCn}
|
|
|
+ </span>
|
|
|
+ );
|
|
|
+ })
|
|
|
+ ) : e.permissionSource === "follows_video_to_hashtag" ? (
|
|
|
+ <span className="muted">随 video_to_hashtag 链放行</span>
|
|
|
+ ) : (
|
|
|
+ <span className="muted">无判定门控</span>
|
|
|
+ )}
|
|
|
+ <span className="walk-edge-budget">
|
|
|
+ 预算:{e.budget ? budgetText(e.budget) : "无显式预算"}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </button>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|