|
|
@@ -0,0 +1,942 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+import {
|
|
|
+ ChevronRight,
|
|
|
+ FileJson,
|
|
|
+ GitBranch,
|
|
|
+ ListFilter,
|
|
|
+ PanelRightOpen,
|
|
|
+ ShieldCheck,
|
|
|
+ Target
|
|
|
+} from "lucide-react";
|
|
|
+import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
+import { AppShell } from "@/components/layout/AppShell";
|
|
|
+import { StatusBadge } from "@/components/badges/StatusBadge";
|
|
|
+import {
|
|
|
+ getContentItems,
|
|
|
+ getDashboard,
|
|
|
+ getQueries,
|
|
|
+ getRuntimeFile,
|
|
|
+ getRuntimeFiles,
|
|
|
+ getTimeline
|
|
|
+} from "@/lib/api/client";
|
|
|
+import type {
|
|
|
+ ContentItemsResponse,
|
|
|
+ DashboardResponse,
|
|
|
+ QueryListResponse,
|
|
|
+ RuleApplicationSummary,
|
|
|
+ RuntimeFileResponse,
|
|
|
+ RuntimeFilesResponse,
|
|
|
+ StageConclusion,
|
|
|
+ TimelineResponse,
|
|
|
+} from "@/lib/api/types";
|
|
|
+import { compactValue } from "@/lib/status/status";
|
|
|
+
|
|
|
+type DashboardData = {
|
|
|
+ dashboard: DashboardResponse;
|
|
|
+ queries: QueryListResponse;
|
|
|
+ contentItems: ContentItemsResponse;
|
|
|
+ timeline: TimelineResponse;
|
|
|
+ runtimeFiles: RuntimeFilesResponse;
|
|
|
+ sourceContext: RuntimeFileResponse | null;
|
|
|
+ patternSeed: RuntimeFileResponse | null;
|
|
|
+};
|
|
|
+
|
|
|
+type DrawerContent =
|
|
|
+ | { kind: "technical"; title: string; payload: unknown }
|
|
|
+ | { kind: "rule"; title: string; payload: unknown }
|
|
|
+ | { kind: "walk"; title: string; payload: unknown }
|
|
|
+ | null;
|
|
|
+
|
|
|
+async function optionalRuntimeFile(runId: string, filename: string): Promise<RuntimeFileResponse | null> {
|
|
|
+ try {
|
|
|
+ return await getRuntimeFile(runId, filename, 80);
|
|
|
+ } catch {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export function RunDashboardPage({ runId }: { runId: string }) {
|
|
|
+ const [activeStage, setActiveStage] = useState("source");
|
|
|
+ const [data, setData] = useState<DashboardData | null>(null);
|
|
|
+ const [runtimeFile, setRuntimeFile] = useState<RuntimeFileResponse | null>(null);
|
|
|
+ const [drawer, setDrawer] = useState<DrawerContent>(null);
|
|
|
+ const [error, setError] = useState<string | null>(null);
|
|
|
+ const [loading, setLoading] = useState(true);
|
|
|
+
|
|
|
+ const load = useCallback(async () => {
|
|
|
+ setLoading(true);
|
|
|
+ setError(null);
|
|
|
+ try {
|
|
|
+ const [dashboard, queries, contentItems, timeline, runtimeFiles, sourceContext, patternSeed] = await Promise.all([
|
|
|
+ getDashboard(runId),
|
|
|
+ getQueries(runId),
|
|
|
+ getContentItems(runId),
|
|
|
+ getTimeline(runId),
|
|
|
+ getRuntimeFiles(runId),
|
|
|
+ optionalRuntimeFile(runId, "source_context.json"),
|
|
|
+ optionalRuntimeFile(runId, "pattern_seed_pack.json")
|
|
|
+ ]);
|
|
|
+ setData({ dashboard, queries, contentItems, timeline, runtimeFiles, sourceContext, patternSeed });
|
|
|
+ } catch (err) {
|
|
|
+ setError(err instanceof Error ? err.message : String(err));
|
|
|
+ } finally {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ }, [runId]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ void load();
|
|
|
+ }, [load]);
|
|
|
+
|
|
|
+ async function openRuntimeFile(filename: string) {
|
|
|
+ const file = await getRuntimeFile(runId, filename, 80);
|
|
|
+ setRuntimeFile(file);
|
|
|
+ setDrawer({
|
|
|
+ kind: "technical",
|
|
|
+ title: filename,
|
|
|
+ payload: file.records || file.data || {}
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <AppShell
|
|
|
+ onRefresh={load}
|
|
|
+ showBack
|
|
|
+ toolbarLeading={
|
|
|
+ data ? (
|
|
|
+ <button
|
|
|
+ className="text-button"
|
|
|
+ onClick={() =>
|
|
|
+ setDrawer({
|
|
|
+ kind: "technical",
|
|
|
+ title: "本次 run metadata",
|
|
|
+ payload: {
|
|
|
+ summary: data.dashboard.summary,
|
|
|
+ technical_refs: data.dashboard.technical_refs,
|
|
|
+ validation: data.dashboard.validation,
|
|
|
+ files: data.dashboard.files
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ type="button"
|
|
|
+ >
|
|
|
+ <PanelRightOpen size={15} />
|
|
|
+ 本次 run metadata
|
|
|
+ </button>
|
|
|
+ ) : null
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {loading ? <div className="loading-state">加载中</div> : null}
|
|
|
+ {error ? <div className="error-state">{error}</div> : null}
|
|
|
+
|
|
|
+ {data ? (
|
|
|
+ <section className="detail-panel business-page">
|
|
|
+ <StageNavigationCards
|
|
|
+ dashboard={data.dashboard}
|
|
|
+ stages={data.dashboard.stage_conclusions}
|
|
|
+ activeStage={activeStage}
|
|
|
+ onSelect={setActiveStage}
|
|
|
+ />
|
|
|
+ <StagePanel
|
|
|
+ activeStage={activeStage}
|
|
|
+ data={data}
|
|
|
+ runtimeFile={runtimeFile}
|
|
|
+ onOpenRuntimeFile={openRuntimeFile}
|
|
|
+ onOpenDrawer={setDrawer}
|
|
|
+ />
|
|
|
+ </section>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ <TechnicalDetailsDrawer drawer={drawer} onClose={() => setDrawer(null)} />
|
|
|
+ </AppShell>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function StageNavigationCards({
|
|
|
+ dashboard,
|
|
|
+ stages,
|
|
|
+ activeStage,
|
|
|
+ onSelect
|
|
|
+}: {
|
|
|
+ dashboard: DashboardResponse;
|
|
|
+ stages: StageConclusion[];
|
|
|
+ activeStage: string;
|
|
|
+ onSelect: (stageId: string) => void;
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <nav className="stage-nav-grid" aria-label="运行阶段">
|
|
|
+ {stages.map((stage) => (
|
|
|
+ <button
|
|
|
+ className={`stage-nav-card ${stage.status} ${activeStage === stage.stage_id ? "active" : ""}`}
|
|
|
+ key={stage.stage_id}
|
|
|
+ onClick={() => onSelect(stage.stage_id)}
|
|
|
+ type="button"
|
|
|
+ >
|
|
|
+ <div className="stage-nav-top">
|
|
|
+ <strong>{stage.label}</strong>
|
|
|
+ {stageCount(dashboard, stage.stage_id) !== null ? (
|
|
|
+ <span className="stage-nav-count">{stageCount(dashboard, stage.stage_id)}</span>
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ <div className="stage-nav-body">
|
|
|
+ <span>{stage.headline}</span>
|
|
|
+ <small>{stage.metric}</small>
|
|
|
+ </div>
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </nav>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function stageCount(dashboard: DashboardResponse, stageId: string): number | null {
|
|
|
+ const counts = dashboard.counts || {};
|
|
|
+ const summary = dashboard.business_summary;
|
|
|
+ if (stageId === "query") return summary.query_count ?? counts.queries ?? 0;
|
|
|
+ if (stageId === "platform") return summary.content_count ?? counts.discovered_content_items ?? 0;
|
|
|
+ if (stageId === "judge") return counts.rule_decisions ?? 0;
|
|
|
+ if (stageId === "walk") return counts.walk_actions ?? 0;
|
|
|
+ if (stageId === "asset") return summary.asset_count ?? 0;
|
|
|
+ return null;
|
|
|
+}
|
|
|
+
|
|
|
+function queryStageStatus(query: Record<string, unknown>): { status: string; label: string } {
|
|
|
+ if (query.failure_reason) {
|
|
|
+ return { status: "failed", label: "平台失败" };
|
|
|
+ }
|
|
|
+ return { status: "success", label: "生成成功" };
|
|
|
+}
|
|
|
+
|
|
|
+function queryGenerationMethodLabel(method: unknown): string {
|
|
|
+ const value = String(method || "");
|
|
|
+ const labels: Record<string, string> = {
|
|
|
+ item_single: "原词直搜",
|
|
|
+ llm_variant: "AI 扩写",
|
|
|
+ query_next_page: "翻页搜索",
|
|
|
+ tag_query: "Tag 搜索"
|
|
|
+ };
|
|
|
+ return labels[value] || compactValue(method);
|
|
|
+}
|
|
|
+
|
|
|
+function queryGenerationExplanation(query: Record<string, unknown>): Record<string, unknown> {
|
|
|
+ const seedRef = query.pattern_seed_ref && typeof query.pattern_seed_ref === "object"
|
|
|
+ ? query.pattern_seed_ref as Record<string, unknown>
|
|
|
+ : {};
|
|
|
+ const llmInput = query.llm_input_evidence && typeof query.llm_input_evidence === "object"
|
|
|
+ ? query.llm_input_evidence as Record<string, unknown>
|
|
|
+ : {};
|
|
|
+ const method = String(query.search_query_generation_method || "");
|
|
|
+ const promptText =
|
|
|
+ method === "llm_variant"
|
|
|
+ ? "AI 扩写:基于 seed_term、分类路径、元素证据和已有 query,生成一条更适合抖音搜索的变体 query。"
|
|
|
+ : method === "item_single"
|
|
|
+ ? "原词直搜:不调用 LLM,直接把 DemandAgent seed_term 作为搜索词。"
|
|
|
+ : method === "query_next_page"
|
|
|
+ ? "翻页搜索:不调用 LLM,沿用上一页 query 和 next_cursor 拉取下一页。"
|
|
|
+ : "按当前生成方式生成搜索词。";
|
|
|
+ return {
|
|
|
+ "生成出的 query": query.search_query,
|
|
|
+ "生成方式": queryGenerationMethodLabel(method),
|
|
|
+ "种子词": seedRef.seed_term || llmInput.seed_term || query.search_query,
|
|
|
+ "父 query": query.llm_variant_of || query.parent_search_query_id || "无",
|
|
|
+ "提示词口径": promptText,
|
|
|
+ "提示词版本": query.llm_prompt_version || "不适用",
|
|
|
+ "LLM 模型": query.llm_generation_model || "不适用",
|
|
|
+ "输入证据摘要": {
|
|
|
+ seed_terms: llmInput.seed_terms || seedRef.seed_terms || seedRef.seed_term,
|
|
|
+ itemset_ids: llmInput.itemset_ids || seedRef.itemset_ids,
|
|
|
+ category_bindings: llmInput.category_bindings,
|
|
|
+ element_bindings: llmInput.element_bindings,
|
|
|
+ source_post_id: llmInput.source_post_id || seedRef.source_post_id,
|
|
|
+ pattern_execution_id: llmInput.pattern_execution_id || seedRef.pattern_execution_id,
|
|
|
+ existing_search_queries: llmInput.existing_search_queries
|
|
|
+ },
|
|
|
+ "原始记录": query
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function isLlmVariantQuery(query: Record<string, unknown>): boolean {
|
|
|
+ return query.search_query_generation_method === "llm_variant";
|
|
|
+}
|
|
|
+
|
|
|
+function seedTermForQuery(query: Record<string, unknown>): unknown {
|
|
|
+ const seedRef = query.pattern_seed_ref && typeof query.pattern_seed_ref === "object"
|
|
|
+ ? query.pattern_seed_ref as Record<string, unknown>
|
|
|
+ : {};
|
|
|
+ const llmInput = query.llm_input_evidence && typeof query.llm_input_evidence === "object"
|
|
|
+ ? query.llm_input_evidence as Record<string, unknown>
|
|
|
+ : {};
|
|
|
+ return llmInput.seed_term || seedRef.seed_term || query.search_query;
|
|
|
+}
|
|
|
+
|
|
|
+function llmVariantPromptPayload(query: Record<string, unknown>): Record<string, unknown> {
|
|
|
+ const llmInput = query.llm_input_evidence && typeof query.llm_input_evidence === "object"
|
|
|
+ ? query.llm_input_evidence as Record<string, unknown>
|
|
|
+ : {};
|
|
|
+ const seedTerm = seedTermForQuery(query);
|
|
|
+ return {
|
|
|
+ "这个提示词在做什么": "拿一个 DemandAgent 种子词,结合 Pattern 证据,让 LLM 生成一条相邻但不重复的抖音搜索 query。",
|
|
|
+ "种子词": seedTerm,
|
|
|
+ "生成结果": query.search_query,
|
|
|
+ "父 query": query.llm_variant_of || "无",
|
|
|
+ "提示词版本": query.llm_prompt_version || "query_variant.v1",
|
|
|
+ "LLM 模型": query.llm_generation_model || "缺失",
|
|
|
+ messages: [
|
|
|
+ {
|
|
|
+ role: "system",
|
|
|
+ content: (
|
|
|
+ "You generate one concise Chinese short-video search query. "
|
|
|
+ + "Return exactly one plain query string. Do not return JSON, "
|
|
|
+ + "lists, quotes, explanations, or multiple lines."
|
|
|
+ )
|
|
|
+ },
|
|
|
+ {
|
|
|
+ role: "user",
|
|
|
+ content: [
|
|
|
+ "Seed term:",
|
|
|
+ compactValue(seedTerm),
|
|
|
+ "",
|
|
|
+ "Evidence context:",
|
|
|
+ JSON.stringify(llmInput, null, 2),
|
|
|
+ "",
|
|
|
+ "Create one adjacent search phrase that stays faithful to the evidence. Avoid any phrase listed in existing_search_queries."
|
|
|
+ ].join("\n")
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "输入证据摘要": {
|
|
|
+ seed_terms: llmInput.seed_terms,
|
|
|
+ itemset_ids: llmInput.itemset_ids,
|
|
|
+ category_bindings: llmInput.category_bindings,
|
|
|
+ element_bindings: llmInput.element_bindings,
|
|
|
+ existing_search_queries: llmInput.existing_search_queries
|
|
|
+ }
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function primaryQuerySource(item: Record<string, unknown>): Record<string, unknown> {
|
|
|
+ const sources = Array.isArray(item.query_sources) ? item.query_sources : [];
|
|
|
+ const first = sources[0];
|
|
|
+ if (first && typeof first === "object") {
|
|
|
+ return first as Record<string, unknown>;
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ search_query_id: item.search_query_id,
|
|
|
+ search_query: item.search_query,
|
|
|
+ search_query_generation_method: item.search_query_generation_method
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+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 {};
|
|
|
+ }
|
|
|
+ return file.data as Record<string, unknown>;
|
|
|
+}
|
|
|
+
|
|
|
+function evidencePackFrom(sourceContext: Record<string, unknown>): Record<string, unknown> {
|
|
|
+ const extData = sourceContext.ext_data;
|
|
|
+ if (extData && typeof extData === "object" && "evidence_pack" in extData) {
|
|
|
+ const evidencePack = (extData as Record<string, unknown>).evidence_pack;
|
|
|
+ return evidencePack && typeof evidencePack === "object" ? evidencePack as Record<string, unknown> : {};
|
|
|
+ }
|
|
|
+ return {};
|
|
|
+}
|
|
|
+
|
|
|
+function firstCategoryPath(patternSeed: Record<string, unknown>): string {
|
|
|
+ const bindings = Array.isArray(patternSeed.category_bindings) ? patternSeed.category_bindings : [];
|
|
|
+ const first = bindings[0];
|
|
|
+ if (first && typeof first === "object") {
|
|
|
+ return compactValue((first as Record<string, unknown>).category_path || (first as Record<string, unknown>).category_full_path);
|
|
|
+ }
|
|
|
+ const itemsets = Array.isArray(patternSeed.itemsets) ? patternSeed.itemsets : [];
|
|
|
+ const firstItemset = itemsets[0];
|
|
|
+ if (firstItemset && typeof firstItemset === "object") {
|
|
|
+ return compactValue((firstItemset as Record<string, unknown>).category_path);
|
|
|
+ }
|
|
|
+ return "缺失";
|
|
|
+}
|
|
|
+
|
|
|
+function SourceEvidenceSummary({
|
|
|
+ sourceContext,
|
|
|
+ patternSeed
|
|
|
+}: {
|
|
|
+ sourceContext: RuntimeFileResponse | null;
|
|
|
+ patternSeed: RuntimeFileResponse | null;
|
|
|
+}) {
|
|
|
+ const source = runtimeData(sourceContext);
|
|
|
+ const seed = runtimeData(patternSeed);
|
|
|
+ const evidencePack = evidencePackFrom(source);
|
|
|
+ const extData = source.ext_data && typeof source.ext_data === "object"
|
|
|
+ ? source.ext_data as Record<string, unknown>
|
|
|
+ : {};
|
|
|
+ const seedTerms = Array.isArray(seed.seed_terms) ? seed.seed_terms : evidencePack.seed_terms;
|
|
|
+ const matchedPostIds = Array.isArray(seed.matched_post_ids) ? seed.matched_post_ids : evidencePack.matched_post_ids;
|
|
|
+ return (
|
|
|
+ <div className="source-summary-grid">
|
|
|
+ <div>
|
|
|
+ <span>需求名称</span>
|
|
|
+ <strong>{compactValue(source.name || extData.type)}</strong>
|
|
|
+ <small>{compactValue(extData.desc || extData.reason)}</small>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <span>Pattern 来源</span>
|
|
|
+ <strong>{compactValue(seed.pattern_source_system || evidencePack.pattern_source_system)}</strong>
|
|
|
+ <small>Pattern 执行 ID:{compactValue(seed.pattern_execution_id || evidencePack.pattern_execution_id)}</small>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <span>种子词</span>
|
|
|
+ <strong>{compactValue(seedTerms)}</strong>
|
|
|
+ <small>Itemset:{compactValue(seed.itemset_ids || evidencePack.itemset_ids)}</small>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <span>Pattern 分类路径</span>
|
|
|
+ <strong>{firstCategoryPath(seed)}</strong>
|
|
|
+ <small>来源样本:{compactValue(seed.source_post_id || evidencePack.source_post_id)}</small>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <span>证据规模</span>
|
|
|
+ <strong>{Array.isArray(matchedPostIds) ? `${matchedPostIds.length} 条匹配样本` : "缺失"}</strong>
|
|
|
+ <small>验证状态:{compactValue(seed.validation_status || evidencePack.validation_status || source.validation_status)}</small>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function StagePanel({
|
|
|
+ activeStage,
|
|
|
+ data,
|
|
|
+ runtimeFile,
|
|
|
+ onOpenRuntimeFile,
|
|
|
+ onOpenDrawer
|
|
|
+}: {
|
|
|
+ activeStage: string;
|
|
|
+ data: DashboardData;
|
|
|
+ runtimeFile: RuntimeFileResponse | null;
|
|
|
+ onOpenRuntimeFile: (filename: string) => void;
|
|
|
+ onOpenDrawer: (drawer: DrawerContent) => void;
|
|
|
+}) {
|
|
|
+ if (activeStage === "source") {
|
|
|
+ return (
|
|
|
+ <BusinessSection title="数据源结论" icon={<Target size={17} />}>
|
|
|
+ <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "source")} />
|
|
|
+ <SourceEvidenceSummary sourceContext={data.sourceContext} patternSeed={data.patternSeed} />
|
|
|
+ <div className="business-action-row">
|
|
|
+ <button className="text-button" onClick={() => onOpenRuntimeFile("source_context.json")} type="button">
|
|
|
+ <FileJson size={15} />
|
|
|
+ 查看需求证据
|
|
|
+ </button>
|
|
|
+ <button className="text-button" onClick={() => onOpenRuntimeFile("pattern_seed_pack.json")} type="button">
|
|
|
+ <FileJson size={15} />
|
|
|
+ 查看 Pattern 种子
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </BusinessSection>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (activeStage === "query") {
|
|
|
+ return (
|
|
|
+ <BusinessSection title="Query 效果" icon={<ListFilter size={17} />}>
|
|
|
+ <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "query")} />
|
|
|
+ <div className="business-card-list">
|
|
|
+ {data.queries.items.length ? data.queries.items.map((query, index) => (
|
|
|
+ <div className="business-record-card" key={String(query.search_query_id || index)}>
|
|
|
+ <strong>{compactValue(query.search_query)}</strong>
|
|
|
+ <div className="badge-row">
|
|
|
+ <span className="badge">{queryGenerationMethodLabel(query.search_query_generation_method)}</span>
|
|
|
+ <span className={`badge ${queryStageStatus(query).status}`}>
|
|
|
+ {queryStageStatus(query).label}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div className="query-meta-line">
|
|
|
+ <span>种子词:{compactValue((query.pattern_seed_ref as Record<string, unknown> | undefined)?.seed_term)}</span>
|
|
|
+ <span>Query ID:{compactValue(query.search_query_id)}</span>
|
|
|
+ </div>
|
|
|
+ <span>{compactValue(query.failure_reason || "已生成,可在判断模块查看内容结果")}</span>
|
|
|
+ <div className="business-action-row">
|
|
|
+ <button
|
|
|
+ className="text-button"
|
|
|
+ onClick={() =>
|
|
|
+ onOpenDrawer({
|
|
|
+ kind: "technical",
|
|
|
+ title: `Query 生成依据:${compactValue(query.search_query)}`,
|
|
|
+ payload: queryGenerationExplanation(query)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ type="button"
|
|
|
+ >
|
|
|
+ 查看生成依据
|
|
|
+ </button>
|
|
|
+ {isLlmVariantQuery(query) ? (
|
|
|
+ <button
|
|
|
+ className="text-button"
|
|
|
+ onClick={() =>
|
|
|
+ onOpenDrawer({
|
|
|
+ kind: "technical",
|
|
|
+ title: `AI 扩写提示词:${compactValue(query.search_query)}`,
|
|
|
+ payload: llmVariantPromptPayload(query)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ type="button"
|
|
|
+ >
|
|
|
+ 查看 AI 扩写提示词
|
|
|
+ </button>
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )) : <div className="empty-state">没有 query 记录</div>}
|
|
|
+ </div>
|
|
|
+ </BusinessSection>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (activeStage === "platform") {
|
|
|
+ return (
|
|
|
+ <BusinessSection title="平台发现内容" icon={<Target size={17} />}>
|
|
|
+ <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "platform")} />
|
|
|
+ <div className="business-card-list">
|
|
|
+ {data.contentItems.items.length ? data.contentItems.items.map((item, index) => (
|
|
|
+ <div className="business-record-card" key={String(item.platform_content_id || index)}>
|
|
|
+ <div className="content-card-heading">
|
|
|
+ <span>平台内容 #{index + 1}</span>
|
|
|
+ <strong>{compactValue(item.title || item.description || "抖音视频")}</strong>
|
|
|
+ </div>
|
|
|
+ <div className="content-meta-grid">
|
|
|
+ <span>
|
|
|
+ 抖音视频 ID
|
|
|
+ <strong>{compactValue(item.platform_content_id)}</strong>
|
|
|
+ </span>
|
|
|
+ <span>
|
|
|
+ 来源 Query
|
|
|
+ <strong>{compactValue(primaryQuerySource(item).search_query_id)}</strong>
|
|
|
+ </span>
|
|
|
+ <span>
|
|
|
+ 搜索关键词
|
|
|
+ <strong>{compactValue(primaryQuerySource(item).search_query)}</strong>
|
|
|
+ </span>
|
|
|
+ <span>
|
|
|
+ Query 类型
|
|
|
+ <strong>{queryGenerationMethodLabel(primaryQuerySource(item).search_query_generation_method)}</strong>
|
|
|
+ </span>
|
|
|
+ <span>
|
|
|
+ 抖音作者
|
|
|
+ <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>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )) : <div className="empty-state">还没有发现内容;请先查看 Query 或平台失败原因</div>}
|
|
|
+ </div>
|
|
|
+ </BusinessSection>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (activeStage === "judge") {
|
|
|
+ return (
|
|
|
+ <BusinessSection title="策略包如何施加到内容判断" icon={<ShieldCheck size={17} />}>
|
|
|
+ <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "judge")} />
|
|
|
+ <div className="rule-chain">
|
|
|
+ <span>内容</span>
|
|
|
+ <ChevronRight size={16} />
|
|
|
+ <span>EvidenceBundle</span>
|
|
|
+ <ChevronRight size={16} />
|
|
|
+ <span>Content Rule Pack V1</span>
|
|
|
+ <ChevronRight size={16} />
|
|
|
+ <span>硬性筛选门槛</span>
|
|
|
+ <ChevronRight size={16} />
|
|
|
+ <span>Scorecard</span>
|
|
|
+ <ChevronRight size={16} />
|
|
|
+ <span>Decision</span>
|
|
|
+ </div>
|
|
|
+ <div className="business-card-list">
|
|
|
+ {data.dashboard.rule_application_summary.length ? data.dashboard.rule_application_summary.map((item, index) => (
|
|
|
+ <RuleApplicationCard
|
|
|
+ item={item}
|
|
|
+ key={item.decision_id || index}
|
|
|
+ onOpen={() =>
|
|
|
+ onOpenDrawer({
|
|
|
+ kind: "rule",
|
|
|
+ title: item.content_title || item.platform_content_id || "规则详情",
|
|
|
+ payload: item
|
|
|
+ })
|
|
|
+ }
|
|
|
+ />
|
|
|
+ )) : <div className="empty-state">还没有内容进入规则判断</div>}
|
|
|
+ </div>
|
|
|
+ </BusinessSection>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (activeStage === "walk") {
|
|
|
+ return (
|
|
|
+ <BusinessSection title="游走路径图" icon={<GitBranch size={17} />}>
|
|
|
+ <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "walk")} />
|
|
|
+ <WalkGraphCanvas
|
|
|
+ graph={data.dashboard.walk_graph}
|
|
|
+ onOpen={(payload) =>
|
|
|
+ onOpenDrawer({
|
|
|
+ kind: "walk",
|
|
|
+ title: String(payload.label || payload.id || "游走详情"),
|
|
|
+ payload
|
|
|
+ })
|
|
|
+ }
|
|
|
+ />
|
|
|
+ </BusinessSection>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (activeStage === "asset") {
|
|
|
+ return (
|
|
|
+ <BusinessSection title="资产沉淀结果" icon={<Target size={17} />}>
|
|
|
+ <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "asset")} />
|
|
|
+ <div className="business-card-list">
|
|
|
+ <div className="business-record-card">
|
|
|
+ <strong>内容资产</strong>
|
|
|
+ <span>入池:{compactValue(data.dashboard.business_summary.kept_count)}</span>
|
|
|
+ <span>待复看:{compactValue(data.dashboard.business_summary.review_count)}</span>
|
|
|
+ <span>淘汰:{compactValue(data.dashboard.business_summary.rejected_count)}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </BusinessSection>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (activeStage === "learning") {
|
|
|
+ return (
|
|
|
+ <BusinessSection title="策略学习结论" icon={<Target size={17} />}>
|
|
|
+ <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "learning")} />
|
|
|
+ <div className="business-card-list">
|
|
|
+ <div className="business-record-card">
|
|
|
+ <strong>{data.dashboard.strategy_review_status}</strong>
|
|
|
+ <span>策略复盘只展示业务建议;原始 review 可在技术详情查看。</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </BusinessSection>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ return (
|
|
|
+ <BusinessSection title={runtimeFile ? runtimeFile.filename : "技术详情"} icon={<FileJson size={17} />}>
|
|
|
+ {runtimeFile ? (
|
|
|
+ <pre className="code-block">
|
|
|
+ {JSON.stringify(runtimeFile.records || runtimeFile.data || {}, null, 2)}
|
|
|
+ </pre>
|
|
|
+ ) : (
|
|
|
+ <div className="empty-state">请选择 runtime 文件</div>
|
|
|
+ )}
|
|
|
+ </BusinessSection>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function BusinessSection({ title, icon, children }: { title: string; icon: React.ReactNode; children: React.ReactNode }) {
|
|
|
+ return (
|
|
|
+ <section className="business-section">
|
|
|
+ <div className="section-title">
|
|
|
+ <strong>{icon}{title}</strong>
|
|
|
+ </div>
|
|
|
+ {children}
|
|
|
+ </section>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function ConclusionBody({ stage }: { stage?: StageConclusion }) {
|
|
|
+ if (!stage) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return (
|
|
|
+ <div className="conclusion-body">
|
|
|
+ <StatusBadge status={stage.status} />
|
|
|
+ <strong>{stage.headline}</strong>
|
|
|
+ <span>{stage.detail}</span>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function RuleApplicationCard({
|
|
|
+ item,
|
|
|
+ onOpen
|
|
|
+}: {
|
|
|
+ item: RuleApplicationSummary;
|
|
|
+ onOpen: () => void;
|
|
|
+}) {
|
|
|
+ const title = item.content_title && item.content_title !== item.platform_content_id ? item.content_title : "视频内容";
|
|
|
+ return (
|
|
|
+ <div className="rule-application-card">
|
|
|
+ <div className="rule-card-heading">
|
|
|
+ <span>判断对象</span>
|
|
|
+ <strong>{compactValue(title)}</strong>
|
|
|
+ <small>抖音视频 ID:{compactValue(item.platform_content_id)}</small>
|
|
|
+ </div>
|
|
|
+ <div className="rule-application-flow">
|
|
|
+ <span>
|
|
|
+ 规则包:{rulePackLabel(item.rule_pack)}
|
|
|
+ <small>规则包 ID:{compactValue(item.rule_pack)}</small>
|
|
|
+ </span>
|
|
|
+ <span>
|
|
|
+ 硬性筛选门槛:{hardGateLabel(item)}
|
|
|
+ <small>{reasonLabel(item.decision_reason_code)}</small>
|
|
|
+ </span>
|
|
|
+ <span>
|
|
|
+ Score:{scoreLabel(item.score)}
|
|
|
+ <small>{item.score === null || item.score === undefined ? "未进入打分或分数缺失" : "内容综合分"}</small>
|
|
|
+ </span>
|
|
|
+ <span>
|
|
|
+ 判断结果:{decisionActionLabel(item.decision_action)}
|
|
|
+ <small>{effectStatusLabel(item.content_effect_status)}</small>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div className="business-alert compact">
|
|
|
+ <ShieldCheck size={14} />
|
|
|
+ <span>主原因:{reasonLabel(item.decision_reason_code || item.primary_reason)}</span>
|
|
|
+ </div>
|
|
|
+ <button className="text-button" onClick={onOpen} type="button">
|
|
|
+ 查看规则包详情
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function rulePackLabel(rulePack: unknown): string {
|
|
|
+ const value = String(rulePack || "");
|
|
|
+ const labels: Record<string, string> = {
|
|
|
+ douyin_content_discovery_rule_pack_v1: "内容发现规则包 V1(抖音)",
|
|
|
+ Content_Rule_Pack_V1: "内容发现规则包 V1",
|
|
|
+ "Content Rule Pack V1": "内容发现规则包 V1"
|
|
|
+ };
|
|
|
+ return labels[value] || compactValue(rulePack || "内容发现规则包 V1");
|
|
|
+}
|
|
|
+
|
|
|
+function hardGateLabel(item: RuleApplicationSummary): string {
|
|
|
+ return item.hard_gate_status === "命中" || item.hard_gate_status === "没通过" ? "没通过" : "通过";
|
|
|
+}
|
|
|
+
|
|
|
+function scoreLabel(score: unknown): string {
|
|
|
+ if (score === null || score === undefined || score === "") {
|
|
|
+ return "未打分";
|
|
|
+ }
|
|
|
+ return `${compactValue(score)} / 100`;
|
|
|
+}
|
|
|
+
|
|
|
+function decisionActionLabel(action: unknown): string {
|
|
|
+ const value = String(action || "");
|
|
|
+ const labels: Record<string, string> = {
|
|
|
+ ADD_TO_CONTENT_POOL: "入池",
|
|
|
+ KEEP_CONTENT_FOR_REVIEW: "待复看",
|
|
|
+ REJECT_CONTENT: "淘汰"
|
|
|
+ };
|
|
|
+ return labels[value] || compactValue(action);
|
|
|
+}
|
|
|
+
|
|
|
+function effectStatusLabel(status: unknown): string {
|
|
|
+ const value = String(status || "");
|
|
|
+ const labels: Record<string, string> = {
|
|
|
+ success: "业务效果:成功",
|
|
|
+ pending: "业务效果:待复看",
|
|
|
+ failed: "业务效果:失败",
|
|
|
+ rule_blocked: "业务效果:规则阻断"
|
|
|
+ };
|
|
|
+ return labels[value] || "业务效果未记录";
|
|
|
+}
|
|
|
+
|
|
|
+function reasonLabel(reason: unknown): string {
|
|
|
+ const value = String(reason || "");
|
|
|
+ const labels: Record<string, string> = {
|
|
|
+ content_pattern_recall_required: "Pattern 回扣未通过:内容没有证明能回到本次需求 Pattern",
|
|
|
+ missing_content_portrait: "内容画像缺失:缺少点赞画像或 50+ 判断所需数据",
|
|
|
+ missing_source_evidence: "来源证据缺失:无法追溯这条内容来自哪个 query / path",
|
|
|
+ missing_platform_content_id: "内容身份缺失:缺少平台视频 ID",
|
|
|
+ missing_score: "分数缺失:无法完成分数阈值判断",
|
|
|
+ high_risk_content: "安全风险高:内容命中高风险判断"
|
|
|
+ };
|
|
|
+ return labels[value] || compactValue(reason);
|
|
|
+}
|
|
|
+
|
|
|
+function WalkGraphCanvas({
|
|
|
+ graph,
|
|
|
+ onOpen
|
|
|
+}: {
|
|
|
+ graph: DashboardResponse["walk_graph"];
|
|
|
+ onOpen: (payload: Record<string, unknown>) => void;
|
|
|
+}) {
|
|
|
+ const { nodes, edges } = useMemo(() => toBoardGraph(graph), [graph]);
|
|
|
+ if (!nodes.length || nodes.length === 1 && !edges.length) {
|
|
|
+ return (
|
|
|
+ <div className="walk-empty-board">
|
|
|
+ <GitBranch size={28} />
|
|
|
+ <strong>当前 run 没有可执行游走路径</strong>
|
|
|
+ <span>如果 query 或平台阶段失败,游走图会保持为空;这不是伪造缺口。</span>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ return (
|
|
|
+ <div className="walk-board">
|
|
|
+ <div className="walk-board-inner">
|
|
|
+ <svg className="walk-lines" aria-hidden="true">
|
|
|
+ {edges.map((edge) => (
|
|
|
+ <g key={edge.id}>
|
|
|
+ <line
|
|
|
+ x1={edge.sourcePoint.x}
|
|
|
+ y1={edge.sourcePoint.y}
|
|
|
+ x2={edge.targetPoint.x}
|
|
|
+ y2={edge.targetPoint.y}
|
|
|
+ />
|
|
|
+ <text
|
|
|
+ x={(edge.sourcePoint.x + edge.targetPoint.x) / 2}
|
|
|
+ y={(edge.sourcePoint.y + edge.targetPoint.y) / 2 - 6}
|
|
|
+ >
|
|
|
+ {edge.label}
|
|
|
+ </text>
|
|
|
+ </g>
|
|
|
+ ))}
|
|
|
+ </svg>
|
|
|
+ {nodes.map((node) => (
|
|
|
+ <button
|
|
|
+ className={`walk-node-card ${node.status}`}
|
|
|
+ key={node.id}
|
|
|
+ onClick={() => onOpen(node.payload)}
|
|
|
+ style={{ left: node.x, top: node.y }}
|
|
|
+ type="button"
|
|
|
+ >
|
|
|
+ <strong>{node.label}</strong>
|
|
|
+ <span>{node.type} · {node.status}</span>
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ {edges.map((edge) => (
|
|
|
+ <button
|
|
|
+ className={`walk-edge-chip ${edge.status || "pending"}`}
|
|
|
+ key={`${edge.id}-chip`}
|
|
|
+ onClick={() => onOpen(edge.payload)}
|
|
|
+ style={{
|
|
|
+ left: (edge.sourcePoint.x + edge.targetPoint.x) / 2 - 70,
|
|
|
+ top: (edge.sourcePoint.y + edge.targetPoint.y) / 2 + 8
|
|
|
+ }}
|
|
|
+ type="button"
|
|
|
+ >
|
|
|
+ {edge.label}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function TechnicalDetailsDrawer({
|
|
|
+ drawer,
|
|
|
+ onClose
|
|
|
+}: {
|
|
|
+ drawer: DrawerContent;
|
|
|
+ onClose: () => void;
|
|
|
+}) {
|
|
|
+ if (!drawer) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ const tabs =
|
|
|
+ drawer.kind === "rule"
|
|
|
+ ? ["业务解释", "规则回放", "原始记录"]
|
|
|
+ : drawer.kind === "walk"
|
|
|
+ ? ["业务解释", "边 / 节点", "原始记录"]
|
|
|
+ : ["业务解释", "DB / runtime 对齐", "validation"];
|
|
|
+ return (
|
|
|
+ <div className="drawer-backdrop" onClick={onClose}>
|
|
|
+ <aside className="technical-drawer" onClick={(event) => event.stopPropagation()}>
|
|
|
+ <div className="drawer-header">
|
|
|
+ <div>
|
|
|
+ <span>技术详情</span>
|
|
|
+ <strong>{drawer.title}</strong>
|
|
|
+ </div>
|
|
|
+ <button className="icon-button" onClick={onClose} type="button">×</button>
|
|
|
+ </div>
|
|
|
+ <div className="drawer-tabs">
|
|
|
+ {tabs.map((tab) => <span className="badge" key={tab}>{tab}</span>)}
|
|
|
+ </div>
|
|
|
+ {drawer.kind === "rule" ? (
|
|
|
+ <RuleDrawerContent payload={drawer.payload as RuleApplicationSummary} />
|
|
|
+ ) : (
|
|
|
+ <pre className="code-block">{JSON.stringify(drawer.payload, null, 2)}</pre>
|
|
|
+ )}
|
|
|
+ </aside>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function RuleDrawerContent({ payload }: { payload: RuleApplicationSummary }) {
|
|
|
+ return (
|
|
|
+ <div className="drawer-business-content">
|
|
|
+ <div className="drawer-explain-card">
|
|
|
+ <span>这条规则在判断什么</span>
|
|
|
+ <strong>{rulePackLabel(payload.rule_pack)}</strong>
|
|
|
+ <p>
|
|
|
+ 这套规则包负责判断一条抖音视频是否值得进入内容池。它会先看硬性筛选门槛,
|
|
|
+ 再看内容画像、互动表现、新鲜度等分数。
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div className="drawer-explain-grid">
|
|
|
+ <div>
|
|
|
+ <span>判断对象</span>
|
|
|
+ <strong>抖音视频 ID</strong>
|
|
|
+ <p>{compactValue(payload.platform_content_id)}</p>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <span>规则包编号</span>
|
|
|
+ <strong>{compactValue(payload.rule_pack)}</strong>
|
|
|
+ <p>用于复盘这次 run 到底用了哪一版规则。</p>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <span>硬性筛选门槛</span>
|
|
|
+ <strong>{hardGateLabel(payload)}</strong>
|
|
|
+ <p>{reasonLabel(payload.decision_reason_code)}</p>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <span>最终判断</span>
|
|
|
+ <strong>{decisionActionLabel(payload.decision_action)}</strong>
|
|
|
+ <p>{effectStatusLabel(payload.content_effect_status)}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <details className="raw-details">
|
|
|
+ <summary>查看原始规则回放记录</summary>
|
|
|
+ <pre className="code-block">{JSON.stringify(payload, null, 2)}</pre>
|
|
|
+ </details>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function toBoardGraph(graph: DashboardResponse["walk_graph"]) {
|
|
|
+ const nodes = graph.nodes.map((node, index) => {
|
|
|
+ const col = index % 4;
|
|
|
+ const row = Math.floor(index / 4);
|
|
|
+ return {
|
|
|
+ ...node,
|
|
|
+ x: 38 + col * 230,
|
|
|
+ y: 48 + row * 120,
|
|
|
+ payload: node as unknown as Record<string, unknown>
|
|
|
+ };
|
|
|
+ });
|
|
|
+ const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
|
+ const edges = graph.edges
|
|
|
+ .map((edge) => {
|
|
|
+ const source = nodeById.get(edge.source);
|
|
|
+ const target = nodeById.get(edge.target);
|
|
|
+ if (!source || !target) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ ...edge,
|
|
|
+ label: edge.label || edge.status || "edge",
|
|
|
+ sourcePoint: { x: source.x + 155, y: source.y + 36 },
|
|
|
+ targetPoint: { x: target.x, y: target.y + 36 },
|
|
|
+ payload: edge as unknown as Record<string, unknown>
|
|
|
+ };
|
|
|
+ })
|
|
|
+ .filter((edge): edge is NonNullable<typeof edge> => Boolean(edge));
|
|
|
+ return { nodes, edges };
|
|
|
+}
|