|
|
@@ -0,0 +1,4200 @@
|
|
|
+import { useMemo, useState } from "react";
|
|
|
+
|
|
|
+type PipelineStageId = "dataSource" | "query" | "platform" | "judge" | "walk" | "asset" | "learning";
|
|
|
+type NeedType = "有需求" | "无需求";
|
|
|
+type Tone = "need" | "open";
|
|
|
+type ScenarioStatus = "verified" | "placeholder";
|
|
|
+type SourceLaneId =
|
|
|
+ | "pattern"
|
|
|
+ | "case"
|
|
|
+ | "historical-search-record"
|
|
|
+ | "historical-account"
|
|
|
+ | "hotspot"
|
|
|
+ | "nurtured-account";
|
|
|
+type PostSourceId = "xhs" | "douyin" | "piaoquan";
|
|
|
+
|
|
|
+type PipelineStage = {
|
|
|
+ id: PipelineStageId;
|
|
|
+ label: string;
|
|
|
+};
|
|
|
+
|
|
|
+type SourceLane = {
|
|
|
+ id: SourceLaneId;
|
|
|
+ name: string;
|
|
|
+ needType: NeedType;
|
|
|
+ tone: Tone;
|
|
|
+ summary: string;
|
|
|
+};
|
|
|
+
|
|
|
+type PlatformOption = {
|
|
|
+ id: string;
|
|
|
+ label: string;
|
|
|
+ summary: string;
|
|
|
+};
|
|
|
+
|
|
|
+type Selection = {
|
|
|
+ laneId: SourceLaneId;
|
|
|
+ platformId: string;
|
|
|
+ stageId: PipelineStageId;
|
|
|
+ postSourceId: PostSourceId;
|
|
|
+ dataSourceRecordIdByLane: Record<SourceLaneId, string>;
|
|
|
+ dataSourceFilterByLane: Record<SourceLaneId, string>;
|
|
|
+ dataSourceSearchByLane: Record<SourceLaneId, string>;
|
|
|
+};
|
|
|
+
|
|
|
+type TextRow = {
|
|
|
+ label: string;
|
|
|
+ value: string;
|
|
|
+};
|
|
|
+
|
|
|
+type DetailTable = {
|
|
|
+ columns: string[];
|
|
|
+ rows: string[][];
|
|
|
+};
|
|
|
+
|
|
|
+type QueryEvidence = {
|
|
|
+ title: string;
|
|
|
+ kind: string;
|
|
|
+ source: string;
|
|
|
+};
|
|
|
+
|
|
|
+type PromptBlock = {
|
|
|
+ title: string;
|
|
|
+ content: string;
|
|
|
+ fullContent?: string;
|
|
|
+};
|
|
|
+
|
|
|
+type QueryGroup = {
|
|
|
+ evidenceTitle: string;
|
|
|
+ queries: string[];
|
|
|
+ note: string;
|
|
|
+};
|
|
|
+
|
|
|
+type QueryWorkshop = {
|
|
|
+ evidence: QueryEvidence[];
|
|
|
+ prompts: PromptBlock[];
|
|
|
+ queryGroups: QueryGroup[];
|
|
|
+ selectedQuery: string;
|
|
|
+};
|
|
|
+
|
|
|
+type JudgmentRuleGroupId = "initialRecall" | "walk";
|
|
|
+
|
|
|
+type JudgmentScoreMetric = {
|
|
|
+ dimension: string;
|
|
|
+ weight: string;
|
|
|
+ highScore: string;
|
|
|
+ evidence: string;
|
|
|
+};
|
|
|
+
|
|
|
+type JudgmentRulePack = {
|
|
|
+ id: string;
|
|
|
+ group: JudgmentRuleGroupId;
|
|
|
+ title: string;
|
|
|
+ summary: string;
|
|
|
+ appliesTo: string;
|
|
|
+ scoreLogic: string;
|
|
|
+ signals: string[];
|
|
|
+ hardGates: TextRow[];
|
|
|
+ scoring: JudgmentScoreMetric[];
|
|
|
+ passLine: string;
|
|
|
+ thresholds: TextRow[];
|
|
|
+ actionPolicy: TextRow[];
|
|
|
+ outputs: TextRow[];
|
|
|
+ evidence: TextRow[];
|
|
|
+ example: TextRow[];
|
|
|
+};
|
|
|
+
|
|
|
+type JudgmentRuleGroup = {
|
|
|
+ id: JudgmentRuleGroupId;
|
|
|
+ title: string;
|
|
|
+ summary: string;
|
|
|
+ rules: JudgmentRulePack[];
|
|
|
+};
|
|
|
+
|
|
|
+type DetailSection = {
|
|
|
+ title: string;
|
|
|
+ body?: string;
|
|
|
+ rows?: TextRow[];
|
|
|
+ chips?: string[];
|
|
|
+ table?: DetailTable;
|
|
|
+ modalTable?: DetailTable;
|
|
|
+ queryWorkshop?: QueryWorkshop;
|
|
|
+ images?: string[];
|
|
|
+ postSourceControl?: boolean;
|
|
|
+ tone?: "plain" | "notice" | "warning";
|
|
|
+};
|
|
|
+
|
|
|
+type StageContent = {
|
|
|
+ title: string;
|
|
|
+ status: ScenarioStatus;
|
|
|
+ badge?: string;
|
|
|
+ eyebrow?: string;
|
|
|
+ note?: string;
|
|
|
+ sections: DetailSection[];
|
|
|
+ judgmentRuleGroups?: JudgmentRuleGroup[];
|
|
|
+ traceItems?: string[];
|
|
|
+};
|
|
|
+
|
|
|
+type AssetFlow = {
|
|
|
+ id: string;
|
|
|
+ title: string;
|
|
|
+ target: string;
|
|
|
+ steps: string[];
|
|
|
+};
|
|
|
+
|
|
|
+type LearningMethodStep = {
|
|
|
+ title: string;
|
|
|
+ output: string;
|
|
|
+};
|
|
|
+
|
|
|
+type LearningTraceGroup = {
|
|
|
+ title: string;
|
|
|
+ items: string[];
|
|
|
+};
|
|
|
+
|
|
|
+type LearningRecommendation = {
|
|
|
+ target: string;
|
|
|
+ finding: string;
|
|
|
+ suggestion: string;
|
|
|
+ impact: string;
|
|
|
+};
|
|
|
+
|
|
|
+type LearningExperiment = {
|
|
|
+ title: string;
|
|
|
+ change: string;
|
|
|
+ observe: string;
|
|
|
+};
|
|
|
+
|
|
|
+type DemoScenario = {
|
|
|
+ id: string;
|
|
|
+ laneId: string;
|
|
|
+ platformId: string;
|
|
|
+ label: string;
|
|
|
+ stages: Record<PipelineStageId, StageContent>;
|
|
|
+};
|
|
|
+
|
|
|
+type PostSourceOption = {
|
|
|
+ id: PostSourceId;
|
|
|
+ label: string;
|
|
|
+};
|
|
|
+
|
|
|
+type DataSourceFilterOption = {
|
|
|
+ id: string;
|
|
|
+ label: string;
|
|
|
+};
|
|
|
+
|
|
|
+type PatternListChip = {
|
|
|
+ label: string;
|
|
|
+ meta: string;
|
|
|
+ tone: "red" | "orange" | "blue";
|
|
|
+};
|
|
|
+
|
|
|
+type PatternListCard = {
|
|
|
+ groupTitle: string;
|
|
|
+ chips: PatternListChip[];
|
|
|
+ support: string;
|
|
|
+ ratio: string;
|
|
|
+ itemCount: string;
|
|
|
+};
|
|
|
+
|
|
|
+type DataSourceRecord = {
|
|
|
+ id: string;
|
|
|
+ title: string;
|
|
|
+ subtitle: string;
|
|
|
+ badge: string;
|
|
|
+ status: ScenarioStatus;
|
|
|
+ evidenceStatus: string;
|
|
|
+ filterValue: string;
|
|
|
+ tags: string[];
|
|
|
+ metrics: TextRow[];
|
|
|
+ materialRows: TextRow[];
|
|
|
+ seedRows: TextRow[];
|
|
|
+ materialTable?: DetailTable;
|
|
|
+ seedTable?: DetailTable;
|
|
|
+ note?: string;
|
|
|
+ patternListCard?: PatternListCard;
|
|
|
+ searchableText: string[];
|
|
|
+};
|
|
|
+
|
|
|
+type DataSourcePanelConfig = {
|
|
|
+ laneId: SourceLaneId;
|
|
|
+ title: string;
|
|
|
+ sourceObject: string;
|
|
|
+ filterLabel: string;
|
|
|
+ filterOptions: DataSourceFilterOption[];
|
|
|
+ searchLabel: string;
|
|
|
+ searchPlaceholder: string;
|
|
|
+ emptyText: string;
|
|
|
+ records: DataSourceRecord[];
|
|
|
+};
|
|
|
+
|
|
|
+type WalkPlatformId = "douyin" | "xhs";
|
|
|
+type WalkStartType = "video" | "note" | "author" | "authorWork";
|
|
|
+type WalkExtensionAngle = "content" | "author" | "work" | "tag" | "search" | "coauthor" | "similar";
|
|
|
+type WalkEvidenceStatus = "verified" | "pending";
|
|
|
+type WalkDecision = "continue" | "accept" | "observe" | "reduce" | "stop" | "reject";
|
|
|
+
|
|
|
+type WalkStrategyEvent = {
|
|
|
+ id: string;
|
|
|
+ platformId: WalkPlatformId;
|
|
|
+ platformLabel: string;
|
|
|
+ startType: WalkStartType;
|
|
|
+ startLabel: string;
|
|
|
+ extensionAngle: WalkExtensionAngle;
|
|
|
+ extensionLabel: string;
|
|
|
+ evidenceStatus: WalkEvidenceStatus;
|
|
|
+ fromNode: string;
|
|
|
+ toNode: string;
|
|
|
+ edge: string;
|
|
|
+ depth: string;
|
|
|
+ rulePackId: string;
|
|
|
+ rulePackName: string;
|
|
|
+ score: string;
|
|
|
+ hardGateResult: string;
|
|
|
+ decision: WalkDecision;
|
|
|
+ decisionLabel: string;
|
|
|
+ continueCondition: string;
|
|
|
+ stopCondition: string;
|
|
|
+ reason: string;
|
|
|
+ nextAction: string;
|
|
|
+ output: string;
|
|
|
+ trace: string[];
|
|
|
+ detailRows: TextRow[];
|
|
|
+ ruleRows: TextRow[];
|
|
|
+ evidenceRows: TextRow[];
|
|
|
+ candidateRows: TextRow[];
|
|
|
+};
|
|
|
+
|
|
|
+type WalkStrategyRow = {
|
|
|
+ id: string;
|
|
|
+ platformId: WalkPlatformId;
|
|
|
+ platformLabel: string;
|
|
|
+ startType: WalkStartType;
|
|
|
+ startLabel: string;
|
|
|
+ currentNode: string;
|
|
|
+ optionIds: string[];
|
|
|
+};
|
|
|
+
|
|
|
+type WalkFilterKey = "all" | string;
|
|
|
+
|
|
|
+const pipelineStages: PipelineStage[] = [
|
|
|
+ { id: "dataSource", label: "数据源" },
|
|
|
+ { id: "query", label: "Query" },
|
|
|
+ { id: "platform", label: "Platform" },
|
|
|
+ { id: "judge", label: "判断" },
|
|
|
+ { id: "walk", label: "游走" },
|
|
|
+ { id: "asset", label: "资产清洗沉淀" },
|
|
|
+ { id: "learning", label: "策略学习" },
|
|
|
+];
|
|
|
+
|
|
|
+const sourceLanes: SourceLane[] = [
|
|
|
+ {
|
|
|
+ id: "pattern",
|
|
|
+ name: "Pattern",
|
|
|
+ needType: "有需求",
|
|
|
+ tone: "need",
|
|
|
+ summary: "从 Pattern item 组合出可搜索表达。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "case",
|
|
|
+ name: "Case",
|
|
|
+ needType: "有需求",
|
|
|
+ tone: "need",
|
|
|
+ summary: "从真实内容解构出的下层特征生成搜索表达。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "historical-search-record",
|
|
|
+ name: "历史优质搜索记录",
|
|
|
+ needType: "有需求",
|
|
|
+ tone: "need",
|
|
|
+ summary: "从可追溯的历史有效 query 复用或改写。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "historical-account",
|
|
|
+ name: "历史沉淀账号",
|
|
|
+ needType: "无需求",
|
|
|
+ tone: "open",
|
|
|
+ summary: "从历史作者资产和账号作品反推供给机会。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "hotspot",
|
|
|
+ name: "热点",
|
|
|
+ needType: "无需求",
|
|
|
+ tone: "open",
|
|
|
+ summary: "从热点词、热榜和时效表达生成轻量探索。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "nurtured-account",
|
|
|
+ name: "养号",
|
|
|
+ needType: "无需求",
|
|
|
+ tone: "open",
|
|
|
+ summary: "从平台推荐流采集潜在 case 和作者线索。",
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+const platformOptions: PlatformOption[] = [
|
|
|
+ { id: "douyin", label: "抖音", summary: "本轮唯一实跑平台。" },
|
|
|
+ { id: "xhs-platform", label: "小红书", summary: "待补真实案例。" },
|
|
|
+ { id: "kuaishou", label: "快手", summary: "待补真实案例。" },
|
|
|
+ { id: "bilibili", label: "B站", summary: "待补真实案例。" },
|
|
|
+ { id: "wechat-video", label: "视频号", summary: "待补真实案例。" },
|
|
|
+ { id: "piaoquan", label: "票圈", summary: "待补真实案例。" },
|
|
|
+];
|
|
|
+
|
|
|
+const postSourceOptions: PostSourceOption[] = [
|
|
|
+ { id: "douyin", label: "抖音" },
|
|
|
+ { id: "piaoquan", label: "票圈" },
|
|
|
+ { id: "xhs", label: "小红书" },
|
|
|
+];
|
|
|
+
|
|
|
+const assetFlows: AssetFlow[] = [
|
|
|
+ {
|
|
|
+ id: "content",
|
|
|
+ title: "内容资产入库",
|
|
|
+ target: "视频 / 笔记",
|
|
|
+ steps: ["候选接收", "去重归一", "质量清洗", "内容入库", "状态分层"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "author",
|
|
|
+ title: "作者资产入库",
|
|
|
+ target: "作者 / 账号",
|
|
|
+ steps: ["作者接收", "身份合并", "作品校验", "作者入库", "可游走标记"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "relation",
|
|
|
+ title: "来源关系沉淀",
|
|
|
+ target: "内容-作者-标签",
|
|
|
+ steps: ["关系抽取", "重复合并", "来源归因", "关系沉淀", "可追溯"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "lead",
|
|
|
+ title: "搜索线索沉淀",
|
|
|
+ target: "Query / 标签 / 话题",
|
|
|
+ steps: ["线索接收", "有效性标记", "风险清洗", "线索沉淀", "回写学习"],
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+const learningMethodSteps: LearningMethodStep[] = [
|
|
|
+ { title: "串起一条链", output: "从下层特征、Query、平台召回一路看到入库和表现" },
|
|
|
+ { title: "先看召回", output: "看哪些 query 有结果,哪些 query 空召回或跑偏" },
|
|
|
+ { title: "再看判断", output: "看规则包为什么保留、淘汰、继续或停止" },
|
|
|
+ { title: "对照表现", output: "看入池内容后续播放、完播、点赞、回流是否更好" },
|
|
|
+ { title: "小步回写", output: "一次只改 Prompt、Query、规则包或游走预算中的一个点" },
|
|
|
+];
|
|
|
+
|
|
|
+const learningTraceGroups: LearningTraceGroup[] = [
|
|
|
+ {
|
|
|
+ title: "输入数据",
|
|
|
+ items: ["数据源", "下层特征", "Prompt 版本", "平台"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "召回数据",
|
|
|
+ items: ["Query", "返回数量", "空召回", "接口失败", "cursor"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "判断数据",
|
|
|
+ items: ["规则包", "硬门槛", "评分", "入池", "淘汰"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "游走数据",
|
|
|
+ items: ["来源节点", "可扩展到", "继续/停止", "深度", "预算"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "资产数据",
|
|
|
+ items: ["内容入库", "作者入库", "去重", "来源关系", "搜索线索"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "表现数据",
|
|
|
+ items: ["播放", "完播", "点赞", "收藏", "评论", "回流"],
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+const learningRecommendations: LearningRecommendation[] = [
|
|
|
+ {
|
|
|
+ target: "下层特征",
|
|
|
+ finding: "物品词单独搜容易跑偏",
|
|
|
+ suggestion: "粉色眼影、假睫毛必须绑定内双/肿眼泡",
|
|
|
+ impact: "Query",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ target: "Prompt",
|
|
|
+ finding: "主 query 过长时召回不稳",
|
|
|
+ suggestion: "优先生成“主种子 + 内容类型”的短词",
|
|
|
+ impact: "Query",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ target: "判断规则",
|
|
|
+ finding: "作者强不代表作品强",
|
|
|
+ suggestion: "作者作品逐条重评",
|
|
|
+ impact: "判断 / 游走",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ target: "游走边",
|
|
|
+ finding: "标签扩展噪声高",
|
|
|
+ suggestion: "小预算探索 + 漂移停止",
|
|
|
+ impact: "游走",
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+const learningExperiments: LearningExperiment[] = [
|
|
|
+ {
|
|
|
+ title: "主种子优先实验",
|
|
|
+ change: "先搜“内双肿眼泡 + 眼妆教程”",
|
|
|
+ observe: "返回数量、入池率、跑偏率",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "风格种子绑定实验",
|
|
|
+ change: "清透纯欲眼妆必须绑定内双/肿眼泡",
|
|
|
+ observe: "泛妆容占比、有效候选数",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "物品种子绑定实验",
|
|
|
+ change: "粉色眼影、单簇假睫毛必须绑定内双/肿眼泡",
|
|
|
+ observe: "产品种草占比、淘汰率",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "种子表现回看",
|
|
|
+ change: "入池内容上线后回看来自哪个下层特征",
|
|
|
+ observe: "完播、点赞、收藏、回流",
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+const learningTraceItemsByStage: Record<PipelineStageId, string[]> = {
|
|
|
+ dataSource: ["数据源类型", "素材原文", "策略种子", "证据状态", "筛选/搜索条件"],
|
|
|
+ query: ["Query 输入素材", "Prompt 版本", "Query 组合", "淘汰原因"],
|
|
|
+ platform: ["平台动作", "输入对象", "产出对象", "可用信号", "平台失败原因"],
|
|
|
+ judge: ["规则包", "硬门槛", "软评分", "决策结果", "停止原因"],
|
|
|
+ walk: ["来源节点", "可扩展到", "策略边", "数据状态", "沉淀对象"],
|
|
|
+ asset: ["去重归一", "清洗结果", "入库状态", "来源关系", "搜索线索"],
|
|
|
+ learning: ["成功路径", "失败路径", "策略建议", "下轮动作", "回写状态"],
|
|
|
+};
|
|
|
+
|
|
|
+const walkStrategyEvents: WalkStrategyEvent[] = [
|
|
|
+ {
|
|
|
+ id: "douyin-video-author",
|
|
|
+ platformId: "douyin",
|
|
|
+ platformLabel: "抖音",
|
|
|
+ startType: "video",
|
|
|
+ startLabel: "视频",
|
|
|
+ extensionAngle: "author",
|
|
|
+ extensionLabel: "作者",
|
|
|
+ evidenceStatus: "verified",
|
|
|
+ fromNode: "抖音视频:肿眼泡眼妆教程",
|
|
|
+ toNode: "视频作者:毛头小资",
|
|
|
+ edge: "视频 -> 作者",
|
|
|
+ depth: "任意层;低层优先",
|
|
|
+ rulePackId: "walk-video-author-joint",
|
|
|
+ rulePackName: "视频作者联合决策",
|
|
|
+ score: "82",
|
|
|
+ hardGateResult: "通过",
|
|
|
+ decision: "continue",
|
|
|
+ decisionLabel: "继续扩作者",
|
|
|
+ continueCondition: "内容能回扣原 seed,作者身份字段完整,作者方向没有明显漂移。",
|
|
|
+ stopCondition: "无作者 ID、内容风险高、作者方向与原需求无关,或同作者已扩展过。",
|
|
|
+ reason: "视频能回扣眼妆教程 seed,作者 sec_uid 完整,允许进入作者供给评估。",
|
|
|
+ nextAction: "进入 作者 -> 作者作品",
|
|
|
+ output: "观察作者 + 作者扩展队列",
|
|
|
+ trace: ["内容节点可扩作者", "每层重评作者价值", "同作者去重", "作者通过后进入作品边"],
|
|
|
+ detailRows: [
|
|
|
+ { label: "起点对象", value: "抖音视频 aweme_id=7639700354116783865" },
|
|
|
+ { label: "目标对象", value: "作者 毛头小资 / sec_uid" },
|
|
|
+ { label: "深度", value: "L1,一跳游走" },
|
|
|
+ { label: "沉淀", value: "作者观察资产,等待作品二跳补证" },
|
|
|
+ ],
|
|
|
+ ruleRows: [
|
|
|
+ { label: "硬门槛", value: "无 aweme_id / 无 sec_uid / 高风险 / 明显跑偏" },
|
|
|
+ { label: "软评分", value: "视频强度、作者强度、风险一致、供给收益" },
|
|
|
+ { label: "阈值", value: "视频 80+ 入池;作者 70+ 才扩" },
|
|
|
+ ],
|
|
|
+ evidenceRows: [
|
|
|
+ { label: "已验证接口", value: "douyin_search" },
|
|
|
+ { label: "已拿字段", value: "aweme_id、desc、author.nickname、author.sec_uid、statistics" },
|
|
|
+ { label: "边界", value: "不代表作者一定可沉淀,仍需作者供给评估" },
|
|
|
+ ],
|
|
|
+ candidateRows: [
|
|
|
+ { label: "候选视频", value: "保留为入池候选" },
|
|
|
+ { label: "作者", value: "进入扩展队列" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "douyin-author-works",
|
|
|
+ platformId: "douyin",
|
|
|
+ platformLabel: "抖音",
|
|
|
+ startType: "author",
|
|
|
+ startLabel: "作者",
|
|
|
+ extensionAngle: "work",
|
|
|
+ extensionLabel: "作品",
|
|
|
+ evidenceStatus: "verified",
|
|
|
+ fromNode: "作者:毛头小资",
|
|
|
+ toNode: "作者近期作品",
|
|
|
+ edge: "作者 -> 作者作品",
|
|
|
+ depth: "L1-LN",
|
|
|
+ rulePackId: "walk-author-work",
|
|
|
+ rulePackName: "作者作品二跳",
|
|
|
+ score: "78",
|
|
|
+ hardGateResult: "通过",
|
|
|
+ decision: "continue",
|
|
|
+ decisionLabel: "拉取 20 条",
|
|
|
+ continueCondition: "作者主题稳定、可爬、历史作品仍能回扣原 seed,且预算未耗尽。",
|
|
|
+ stopCondition: "作者作品连续跑偏、重复率过高、有效作品比例低,或作品页不可获取。",
|
|
|
+ reason: "作者作品接口已验证,近期作品仍围绕眼妆教程,可用中预算拉取。",
|
|
|
+ nextAction: "逐条作品走视频候选准入",
|
|
|
+ output: "作者作品候选 + 作者有效率",
|
|
|
+ trace: ["作者节点可扩作品", "作品逐条重评", "翻页受预算控制", "有效率决定是否继续"],
|
|
|
+ detailRows: [
|
|
|
+ { label: "起点对象", value: "作者 sec_uid" },
|
|
|
+ { label: "目标对象", value: "作者近期作品列表" },
|
|
|
+ { label: "深度", value: "L2,作者作品二跳" },
|
|
|
+ { label: "沉淀", value: "作者作品候选、作者有效率、停止/继续原因" },
|
|
|
+ ],
|
|
|
+ ruleRows: [
|
|
|
+ { label: "硬门槛", value: "作者不可爬、连续跑偏、风险作品集中" },
|
|
|
+ { label: "软评分", value: "作品相关、质量稳定、供给新鲜、作者一致、风险低" },
|
|
|
+ { label: "阈值", value: "单作品 75+ 入池;作者作品有效率 >= 40% 才继续扩" },
|
|
|
+ ],
|
|
|
+ evidenceRows: [
|
|
|
+ { label: "已验证接口", value: "douyin_user_videos / blogger" },
|
|
|
+ { label: "已拿字段", value: "aweme_id、desc、author、statistics、has_more、next_cursor" },
|
|
|
+ { label: "边界", value: "作品仍需再过视频候选准入,不直接进入结果" },
|
|
|
+ ],
|
|
|
+ candidateRows: [
|
|
|
+ { label: "作品候选", value: "按视频候选准入逐条判断" },
|
|
|
+ { label: "作者资产", value: "记录 author_tier、有效率、重复率" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "douyin-authorwork-candidate",
|
|
|
+ platformId: "douyin",
|
|
|
+ platformLabel: "抖音",
|
|
|
+ startType: "author",
|
|
|
+ startLabel: "作者",
|
|
|
+ extensionAngle: "content",
|
|
|
+ extensionLabel: "视频",
|
|
|
+ evidenceStatus: "verified",
|
|
|
+ fromNode: "作者作品列表",
|
|
|
+ toNode: "候选视频",
|
|
|
+ edge: "作者作品 -> 候选视频",
|
|
|
+ depth: "任意层",
|
|
|
+ rulePackId: "recall-video-admission",
|
|
|
+ rulePackName: "视频候选准入",
|
|
|
+ score: "80+",
|
|
|
+ hardGateResult: "逐条判断",
|
|
|
+ decision: "accept",
|
|
|
+ decisionLabel: "入池/观察",
|
|
|
+ continueCondition: "作品能回扣原 seed,互动可信,风险低,且不是重复内容。",
|
|
|
+ stopCondition: "作品跑偏、重复、低质、风险高,或作者作品有效率已经跌破阈值。",
|
|
|
+ reason: "作者作品不能自动入池,必须重新判断需求回扣、老年场景、质量和风险。",
|
|
|
+ nextAction: "进入资产清洗沉淀或停止作者路径",
|
|
|
+ output: "候选视频、观察视频、淘汰记录",
|
|
|
+ trace: ["作品回到内容节点", "内容准入重评", "重复停止", "低质停止"],
|
|
|
+ detailRows: [
|
|
|
+ { label: "起点对象", value: "作者作品" },
|
|
|
+ { label: "目标对象", value: "候选视频 / 观察视频 / 淘汰视频" },
|
|
|
+ { label: "深度", value: "L2 结果判断" },
|
|
|
+ { label: "沉淀", value: "候选池、淘汰原因、作者有效率" },
|
|
|
+ ],
|
|
|
+ ruleRows: [
|
|
|
+ { label: "硬门槛", value: "不可用、重复、明显不相关、明显风险" },
|
|
|
+ { label: "软评分", value: "需求回扣、老年场景、可看懂、互动可信、来源质量" },
|
|
|
+ { label: "阈值", value: "80+ 入池;65-79 观察;<65 淘汰" },
|
|
|
+ ],
|
|
|
+ evidenceRows: [
|
|
|
+ { label: "已验证字段", value: "aweme_id、desc、statistics、author.sec_uid" },
|
|
|
+ { label: "待补字段", value: "结构化字幕、评论语义、视频状态细项" },
|
|
|
+ { label: "边界", value: "不把作者强等同于每条作品强" },
|
|
|
+ ],
|
|
|
+ candidateRows: [
|
|
|
+ { label: "候选", value: "强作品入候选池" },
|
|
|
+ { label: "淘汰", value: "记录跑偏、重复、低质、风险" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "douyin-text-tag-search",
|
|
|
+ platformId: "douyin",
|
|
|
+ platformLabel: "抖音",
|
|
|
+ startType: "video",
|
|
|
+ startLabel: "视频",
|
|
|
+ extensionAngle: "tag",
|
|
|
+ extensionLabel: "标签/话题",
|
|
|
+ evidenceStatus: "pending",
|
|
|
+ fromNode: "视频文本:#内双 #肿眼泡 #眼妆",
|
|
|
+ toNode: "标签/话题词 -> 新搜索",
|
|
|
+ edge: "内容文本 -> 标签/话题 -> 新搜索",
|
|
|
+ depth: "L1-LN",
|
|
|
+ rulePackId: "walk-tag-topic-callback",
|
|
|
+ rulePackName: "标签话题回扣",
|
|
|
+ score: "64",
|
|
|
+ hardGateResult: "未锁接口",
|
|
|
+ decision: "reduce",
|
|
|
+ decisionLabel: "小预算探索",
|
|
|
+ continueCondition: "标签/话题能解释回原需求,并且能产生非重复的新搜索入口。",
|
|
|
+ stopCondition: "标签泛化、产品化、风险化,或新搜索结果不能回扣原 seed。",
|
|
|
+ reason: "desc 中可见标签文本,但结构化标签/话题页接口未验证,只能作为小预算线索。",
|
|
|
+ nextAction: "生成少量新 query,结果必须回扣原 seed",
|
|
|
+ output: "待验证线索",
|
|
|
+ trace: ["内容可扩标签", "标签可回到搜索词", "搜索结果再回到内容节点", "漂移停止"],
|
|
|
+ detailRows: [
|
|
|
+ { label: "起点对象", value: "视频标题/desc 中的标签文本" },
|
|
|
+ { label: "目标对象", value: "新搜索 query" },
|
|
|
+ { label: "深度", value: "L2,标签二跳" },
|
|
|
+ { label: "沉淀", value: "待验证线索,不沉淀为已跑通能力" },
|
|
|
+ ],
|
|
|
+ ruleRows: [
|
|
|
+ { label: "硬门槛", value: "标签无法回扣原 seed 或引入风险时停止" },
|
|
|
+ { label: "软评分", value: "回扣强度、可搜性、新增价值、低风险" },
|
|
|
+ { label: "阈值", value: "75+ 继续;60-74 小预算;<60 停止" },
|
|
|
+ ],
|
|
|
+ evidenceRows: [
|
|
|
+ { label: "已验证", value: "desc 文本可能包含 hashtag" },
|
|
|
+ { label: "待验证", value: "结构化话题页 / 标签页 API" },
|
|
|
+ { label: "边界", value: "不能把标签文本当成已验证标签游走接口" },
|
|
|
+ ],
|
|
|
+ candidateRows: [
|
|
|
+ { label: "二跳 query", value: "肿眼泡眼妆教程、内双眼妆步骤" },
|
|
|
+ { label: "停止条件", value: "变成泛美瞳、泛产品种草时停" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "douyin-related-search",
|
|
|
+ platformId: "douyin",
|
|
|
+ platformLabel: "抖音",
|
|
|
+ startType: "video",
|
|
|
+ startLabel: "视频",
|
|
|
+ extensionAngle: "search",
|
|
|
+ extensionLabel: "相关搜索",
|
|
|
+ evidenceStatus: "pending",
|
|
|
+ fromNode: "Top 候选视频",
|
|
|
+ toNode: "平台相关搜索词",
|
|
|
+ edge: "内容 -> 相关搜索/搜索建议 -> 新搜索",
|
|
|
+ depth: "L1-LN",
|
|
|
+ rulePackId: "walk-related-search",
|
|
|
+ rulePackName: "搜索建议二跳",
|
|
|
+ score: "70",
|
|
|
+ hardGateResult: "接口契约待补",
|
|
|
+ decision: "reduce",
|
|
|
+ decisionLabel: "小预算探索",
|
|
|
+ continueCondition: "搜索建议词仍围绕原 seed,能带来新增召回面,且风险可控。",
|
|
|
+ stopCondition: "建议词变成泛词、产品词、营销词,或新结果重复率过高。",
|
|
|
+ reason: "相关搜索在样例中出现,但旧审计没有稳定字段或 endpoint,先作为待验证二跳。",
|
|
|
+ nextAction: "补接口契约后再升级为已验证",
|
|
|
+ output: "待验证二跳词",
|
|
|
+ trace: ["内容可扩搜索词", "搜索词回到内容召回", "结果重评", "低收益停止"],
|
|
|
+ detailRows: [
|
|
|
+ { label: "起点对象", value: "Top 候选视频或搜索结果页" },
|
|
|
+ { label: "目标对象", value: "相关搜索词" },
|
|
|
+ { label: "深度", value: "L2,搜索建议二跳" },
|
|
|
+ { label: "沉淀", value: "待验证二跳词和停止原因" },
|
|
|
+ ],
|
|
|
+ ruleRows: [
|
|
|
+ { label: "硬门槛", value: "无法回扣原需求、风险放大、重复过高" },
|
|
|
+ { label: "软评分", value: "回扣强度、老年适配、平台可搜、新增价值、低风险" },
|
|
|
+ { label: "阈值", value: "75+ 继续;60-74 小预算;<60 停止" },
|
|
|
+ ],
|
|
|
+ evidenceRows: [
|
|
|
+ { label: "已验证", value: "前端样例中有平台返回二跳线索" },
|
|
|
+ { label: "待验证", value: "独立 endpoint、返回字段、分页和稳定性" },
|
|
|
+ { label: "边界", value: "不能在产品上写成已接入搜索建议 API" },
|
|
|
+ ],
|
|
|
+ candidateRows: [
|
|
|
+ { label: "二跳词", value: "硬梗假睫毛、卧蚕怎么画、下至卧蚕笔" },
|
|
|
+ { label: "停止条件", value: "只剩产品词或泛妆容时停" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "xhs-note-author",
|
|
|
+ platformId: "xhs",
|
|
|
+ platformLabel: "小红书",
|
|
|
+ startType: "note",
|
|
|
+ startLabel: "笔记",
|
|
|
+ extensionAngle: "author",
|
|
|
+ extensionLabel: "作者",
|
|
|
+ evidenceStatus: "pending",
|
|
|
+ fromNode: "小红书 Case 笔记",
|
|
|
+ toNode: "笔记作者主页",
|
|
|
+ edge: "笔记 -> 作者",
|
|
|
+ depth: "L1-LN",
|
|
|
+ rulePackId: "walk-author-supply",
|
|
|
+ rulePackName: "作者供给评估",
|
|
|
+ score: "--",
|
|
|
+ hardGateResult: "接口未验证",
|
|
|
+ decision: "observe",
|
|
|
+ decisionLabel: "待验证观察",
|
|
|
+ continueCondition: "笔记作者身份可稳定获取,作者主页和作者笔记字段可验证。",
|
|
|
+ stopCondition: "无法获取作者 ID、主页不可访问,或作者主题无法解释回原 seed。",
|
|
|
+ reason: "内部 Case 有小红书素材字段,但小红书作者主页/作者笔记接口未验证。",
|
|
|
+ nextAction: "进入接口验证清单,不进入默认主路径",
|
|
|
+ output: "待验证作者线索",
|
|
|
+ trace: ["笔记节点可扩作者", "作者接口待验证", "验证后进入作品边", "未验证不进默认预算"],
|
|
|
+ detailRows: [
|
|
|
+ { label: "起点对象", value: "小红书来源 Case / 笔记样例" },
|
|
|
+ { label: "目标对象", value: "作者主页 / 作者笔记" },
|
|
|
+ { label: "深度", value: "L1,小红书作者线索" },
|
|
|
+ { label: "沉淀", value: "待验证作者线索,不作为已接入能力" },
|
|
|
+ ],
|
|
|
+ ruleRows: [
|
|
|
+ { label: "硬门槛", value: "作者 ID、主页、作品接口未验证时不能扩展" },
|
|
|
+ { label: "软评分", value: "作者主题、作品稳定、互动可信、风险" },
|
|
|
+ { label: "阈值", value: "接口跑通后才允许升级为可执行路径" },
|
|
|
+ ],
|
|
|
+ evidenceRows: [
|
|
|
+ { label: "已验证", value: "内部 Case 静态样例存在小红书标题、账号、互动" },
|
|
|
+ { label: "待验证", value: "小红书笔记搜索、作者主页、作者笔记接口" },
|
|
|
+ { label: "边界", value: "不能把 Case 字段等同于平台接口已打通" },
|
|
|
+ ],
|
|
|
+ candidateRows: [
|
|
|
+ { label: "作者线索", value: "单眼皮倩倩 / 小红书账号名" },
|
|
|
+ { label: "下一步", value: "补 author_id、主页作品、分页能力" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "xhs-note-topic",
|
|
|
+ platformId: "xhs",
|
|
|
+ platformLabel: "小红书",
|
|
|
+ startType: "note",
|
|
|
+ startLabel: "笔记",
|
|
|
+ extensionAngle: "tag",
|
|
|
+ extensionLabel: "话题",
|
|
|
+ evidenceStatus: "pending",
|
|
|
+ fromNode: "小红书笔记文本",
|
|
|
+ toNode: "话题标签 -> 同话题笔记",
|
|
|
+ edge: "笔记 -> 话题标签 -> 同话题笔记",
|
|
|
+ depth: "L1-LN",
|
|
|
+ rulePackId: "walk-tag-topic-callback",
|
|
|
+ rulePackName: "标签话题回扣",
|
|
|
+ score: "--",
|
|
|
+ hardGateResult: "接口未验证",
|
|
|
+ decision: "observe",
|
|
|
+ decisionLabel: "待验证观察",
|
|
|
+ continueCondition: "话题能回扣原 seed,并能召回同主题笔记或相关内容。",
|
|
|
+ stopCondition: "话题过泛、营销化、不可获取同话题内容,或同话题结果跑偏。",
|
|
|
+ reason: "小红书话题路径产品上合理,但当前没有已验证话题/同话题笔记接口证据。",
|
|
|
+ nextAction: "补话题页接口和同话题召回字段",
|
|
|
+ output: "待验证话题线索",
|
|
|
+ trace: ["笔记可扩话题", "话题可回到笔记", "同话题内容重评", "接口待验证"],
|
|
|
+ detailRows: [
|
|
|
+ { label: "起点对象", value: "小红书笔记标题/正文" },
|
|
|
+ { label: "目标对象", value: "话题标签与同话题笔记" },
|
|
|
+ { label: "深度", value: "L2,话题二跳" },
|
|
|
+ { label: "沉淀", value: "待验证话题线索" },
|
|
|
+ ],
|
|
|
+ ruleRows: [
|
|
|
+ { label: "硬门槛", value: "话题不能回扣原 seed、噪声过高、接口未验证" },
|
|
|
+ { label: "软评分", value: "话题回扣、同话题笔记质量、重复率、风险" },
|
|
|
+ { label: "阈值", value: "只在接口验证后允许进入小预算探索" },
|
|
|
+ ],
|
|
|
+ evidenceRows: [
|
|
|
+ { label: "已验证", value: "内部 Case 有笔记内容" },
|
|
|
+ { label: "待验证", value: "话题标签、同话题笔记、推荐流" },
|
|
|
+ { label: "边界", value: "当前只能展示产品策略,不写成已接入" },
|
|
|
+ ],
|
|
|
+ candidateRows: [
|
|
|
+ { label: "话题线索", value: "眼妆教程、内双妆容、肿眼泡" },
|
|
|
+ { label: "停止条件", value: "话题泛化到美妆好物或达人营销时停" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "coauthor-explore",
|
|
|
+ platformId: "douyin",
|
|
|
+ platformLabel: "抖音",
|
|
|
+ startType: "video",
|
|
|
+ startLabel: "视频",
|
|
|
+ extensionAngle: "coauthor",
|
|
|
+ extensionLabel: "共创",
|
|
|
+ evidenceStatus: "pending",
|
|
|
+ fromNode: "强相关视频",
|
|
|
+ toNode: "共创视频 / 共创作者",
|
|
|
+ edge: "内容 -> 共创视频 -> 共创作者",
|
|
|
+ depth: "L2-LN",
|
|
|
+ rulePackId: "walk-coauthor-trust",
|
|
|
+ rulePackName: "共创关系可信",
|
|
|
+ score: "--",
|
|
|
+ hardGateResult: "待验证",
|
|
|
+ decision: "stop",
|
|
|
+ decisionLabel: "产品占位",
|
|
|
+ continueCondition: "共创关系来源可解释,共创内容仍回扣 seed,且能识别共创作者身份。",
|
|
|
+ stopCondition: "无关系字段、关系不可解释、共创内容跑偏,或作者方向无法验证。",
|
|
|
+ reason: "共创关系可作为未来探索,但当前没有稳定接口和字段,不进入默认游走。",
|
|
|
+ nextAction: "列入接口验证,不消耗默认预算",
|
|
|
+ output: "产品待验证线索",
|
|
|
+ trace: ["内容可扩共创关系", "共创作者需解释字段", "默认预算为 0", "验证前停止"],
|
|
|
+ detailRows: [
|
|
|
+ { label: "起点对象", value: "强相关视频或作者" },
|
|
|
+ { label: "目标对象", value: "共创视频 / 共创作者" },
|
|
|
+ { label: "深度", value: "L3,产品探索" },
|
|
|
+ { label: "沉淀", value: "待验证线索,不进候选池" },
|
|
|
+ ],
|
|
|
+ ruleRows: [
|
|
|
+ { label: "硬门槛", value: "无共创关系字段、共创作者方向不稳定、无法回扣 seed" },
|
|
|
+ { label: "软评分", value: "关系可信、共创内容质量、作者方向一致、风险低" },
|
|
|
+ { label: "阈值", value: "接口验证前默认停止" },
|
|
|
+ ],
|
|
|
+ evidenceRows: [
|
|
|
+ { label: "已验证", value: "无" },
|
|
|
+ { label: "待验证", value: "共创视频、共创作者、关系强度字段" },
|
|
|
+ { label: "边界", value: "不能在 UI 中表现成已跑通链路" },
|
|
|
+ ],
|
|
|
+ candidateRows: [
|
|
|
+ { label: "探索对象", value: "共创视频 / 共创作者" },
|
|
|
+ { label: "默认动作", value: "停止,等待验证" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "similar-author-explore",
|
|
|
+ platformId: "xhs",
|
|
|
+ platformLabel: "小红书",
|
|
|
+ startType: "author",
|
|
|
+ startLabel: "作者",
|
|
|
+ extensionAngle: "similar",
|
|
|
+ extensionLabel: "相似作者",
|
|
|
+ evidenceStatus: "pending",
|
|
|
+ fromNode: "作者资产",
|
|
|
+ toNode: "相似作者 -> 相似作者作品",
|
|
|
+ edge: "作者 -> 相似作者 -> 相似作者作品",
|
|
|
+ depth: "L2-LN",
|
|
|
+ rulePackId: "walk-similar-author-trust",
|
|
|
+ rulePackName: "相似作者可信",
|
|
|
+ score: "--",
|
|
|
+ hardGateResult: "待验证",
|
|
|
+ decision: "stop",
|
|
|
+ decisionLabel: "产品占位",
|
|
|
+ continueCondition: "相似关系可解释,相似作者主题稳定,作品能回扣原 seed。",
|
|
|
+ stopCondition: "相似原因不可解释、作者方向漂移、无作品验证,或噪声扩散明显。",
|
|
|
+ reason: "相似作者容易噪声扩散,未验证关系来源前不进入生产游走。",
|
|
|
+ nextAction: "验证相似关系来源和可解释字段",
|
|
|
+ output: "产品待验证线索",
|
|
|
+ trace: ["作者可扩相似作者", "相似作者再回到作品", "解释字段必需", "噪声扩散停止"],
|
|
|
+ detailRows: [
|
|
|
+ { label: "起点对象", value: "已评估作者或历史作者资产" },
|
|
|
+ { label: "目标对象", value: "相似作者和其作品" },
|
|
|
+ { label: "深度", value: "L3,产品探索" },
|
|
|
+ { label: "沉淀", value: "待验证线索,不进默认候选" },
|
|
|
+ ],
|
|
|
+ ruleRows: [
|
|
|
+ { label: "硬门槛", value: "相似关系不可解释、作者方向漂移、无作品验证" },
|
|
|
+ { label: "软评分", value: "相似原因、作品稳定、人群适配、低风险" },
|
|
|
+ { label: "阈值", value: "接口和解释字段验证前默认停止" },
|
|
|
+ ],
|
|
|
+ evidenceRows: [
|
|
|
+ { label: "已验证", value: "无" },
|
|
|
+ { label: "待验证", value: "相似作者、相似内容、推荐流" },
|
|
|
+ { label: "边界", value: "白板/图上可画,但主运行记录必须标待验证" },
|
|
|
+ ],
|
|
|
+ candidateRows: [
|
|
|
+ { label: "探索对象", value: "相似作者 / 推荐作者" },
|
|
|
+ { label: "默认动作", value: "停止,等待验证" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+const patternItems = [
|
|
|
+ "眼部结构",
|
|
|
+ "彩妆",
|
|
|
+ "眼妆",
|
|
|
+ "眼影",
|
|
|
+ "眼线",
|
|
|
+ "睫毛",
|
|
|
+ "妆容风格",
|
|
|
+ "通用技法",
|
|
|
+ "构成单元",
|
|
|
+ "时序变化对比",
|
|
|
+ "通用对比",
|
|
|
+ "物品展示",
|
|
|
+ "图示说明",
|
|
|
+ "文字标注",
|
|
|
+ "课堂讲座",
|
|
|
+ "步骤拆解",
|
|
|
+];
|
|
|
+
|
|
|
+const caseImages = [
|
|
|
+ "https://res.cybertogether.net/crawler/image/06db6af1f2a8293cc0be62ca64bf9152.jpeg",
|
|
|
+ "https://res.cybertogether.net/crawler/image/dc81ac645ac25d2bf1aa423818fefc63.jpeg",
|
|
|
+ "https://res.cybertogether.net/crawler/image/fda12beca4c6e9c634a459ba37f0c09e.jpeg",
|
|
|
+ "https://res.cybertogether.net/crawler/image/fe6a813ae6b777913744d733c47c54b2.jpeg",
|
|
|
+];
|
|
|
+
|
|
|
+const caseDecodeTable: DetailTable = {
|
|
|
+ columns: ["来源块", "具体内容", "实质", "形式", "意图"],
|
|
|
+ rows: [
|
|
|
+ ["灵感点", "内双肿眼泡清透纯欲眼妆", "内双肿眼泡、清透纯欲眼妆", "-", "-"],
|
|
|
+ ["目的点", "分享内双肿眼泡眼妆画法", "内双肿眼泡眼妆", "-", "分享"],
|
|
|
+ ["关键点", "分步骤图解教学", "-", "图示说明、课堂讲座、步骤拆解", "-"],
|
|
|
+ ["关键点", "标注式操作指引", "-", "文字标注、操作教程", "-"],
|
|
|
+ ["关键点", "前后对比呈现", "-", "时序变化对比、构成单元", "-"],
|
|
|
+ ["关键点", "粉色系眼影产品 / 单簇假睫毛", "眼影、睫毛", "-", "-"],
|
|
|
+ ["关键点", "刀锋刷工具 / 局部特写展示", "彩妆工具", "通用技法、物品展示", "-"],
|
|
|
+ ],
|
|
|
+};
|
|
|
+
|
|
|
+function buildJudgmentRuleGroups(profile: "pattern" | "case"): JudgmentRuleGroup[] {
|
|
|
+ const seedName = profile === "pattern" ? "Pattern 策略种子" : "Case 策略种子";
|
|
|
+ const seedBasis = profile === "pattern" ? "Pattern 特征组合" : "Case 解构点";
|
|
|
+ const sourceId = profile === "pattern" ? "pattern_id / execution_id" : "channel_content_id / decode_task_id";
|
|
|
+ const exampleSeed =
|
|
|
+ profile === "pattern"
|
|
|
+ ? "祝福词句 + 早安问候 + 分享"
|
|
|
+ : "退休生活焦虑 / 防骗提醒 / 养老金政策类历史 case";
|
|
|
+ const demandObject = "老年视频赛道候选";
|
|
|
+
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ id: "initialRecall",
|
|
|
+ title: "候选准入规则",
|
|
|
+ summary: "先判断视频候选是否可以进入候选池:硬门槛一票否决,软评分决定入池、观察或淘汰。",
|
|
|
+ rules: [
|
|
|
+ {
|
|
|
+ id: `${profile}-recall-video-admission`,
|
|
|
+ group: "initialRecall",
|
|
|
+ title: "视频候选准入规则",
|
|
|
+ summary: "判断候选视频能否回扣需求、seed 和老年场景,决定是否进入候选池。",
|
|
|
+ appliesTo: "待准入视频候选",
|
|
|
+ scoreLogic: "硬门槛 + 100 分准入",
|
|
|
+ signals: ["召回 query", seedName, "视频标题", "正文/话题", "内容场景", "候选来源"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "需求不可回扣", value: "标题、正文、话题均无法解释回原始需求时直接淘汰" },
|
|
|
+ { label: "平台不可用", value: "视频失效、不可访问、字段缺失严重时不入池" },
|
|
|
+ { label: "非目标内容", value: "明显转向年轻娱乐、泛搞笑、纯卖货时淘汰" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "需求回扣", weight: "35%", highScore: "目的点、实质、意图都能解释当前需求", evidence: "seed / query / title / body_text" },
|
|
|
+ { dimension: "老年场景", weight: "25%", highScore: "退休、家庭、健康、防骗、养老金、祝福等场景明确", evidence: "title / tags / comment_terms" },
|
|
|
+ { dimension: "可看懂", weight: "15%", highScore: "标题直白、字幕清晰、节奏不跳脱", evidence: "ocr_text / video_text / title" },
|
|
|
+ { dimension: "互动可信", weight: "15%", highScore: "收藏、评论、转发倾向和目标语义匹配", evidence: "metrics / comments_summary" },
|
|
|
+ { dimension: "来源质量", weight: "10%", highScore: "来自高置信 channel 或历史有效 seed", evidence: "recall_channel / seed_ids" },
|
|
|
+ ],
|
|
|
+ passLine: "80+ 入池;65-79 观察;<65 淘汰;任一硬门槛命中直接淘汰",
|
|
|
+ thresholds: [
|
|
|
+ { label: "入池", value: "总分 >= 80 且无硬门槛命中" },
|
|
|
+ { label: "观察", value: "总分 65-79,可作为弱证据等待作者或二跳补强" },
|
|
|
+ { label: "淘汰", value: "总分 < 65 或硬门槛命中" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "入池", value: "进入统一候选池,保留 seed、query、channel 归因" },
|
|
|
+ { label: "观察", value: "不扩预算,只等作者画像或相关搜索补证" },
|
|
|
+ { label: "淘汰", value: "写入淘汰原因,不进入游走" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "decision", value: "accept / observe / reject" },
|
|
|
+ { label: "score", value: "0-100,附硬门槛命中项" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "seed", value: `${seedBasis}: ${exampleSeed}` },
|
|
|
+ { label: "来源 ID", value: sourceId },
|
|
|
+ { label: "候选字段", value: "content_id / title / body_text / tags / metrics / author_id" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "候选", value: "退休后最怕这 3 件事,子女一定要提醒爸妈" },
|
|
|
+ { label: "结论", value: "需求回扣强、老年场景明确,准入 86,进入候选池" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: `${profile}-recall-senior-fit`,
|
|
|
+ group: "initialRecall",
|
|
|
+ title: "目标人群适配规则",
|
|
|
+ summary: "单独判断内容是否适合 50+、银发和家庭决策场景。",
|
|
|
+ appliesTo: demandObject,
|
|
|
+ scoreLogic: "人群适配评分",
|
|
|
+ signals: ["50+ 画像", "评论人群词", "字幕/口播", "生活场景", "账号受众", "地域/设备线索"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "明显年轻化", value: "黑话密集、潮流梗重、游戏/二次元等非目标语境直接降级" },
|
|
|
+ { label: "理解成本高", value: "字幕缺失且口播快、信息跳跃,老年用户难以理解时不优先入池" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "画像匹配", weight: "30%", highScore: "50+ 占比/TGI 或评论语义强匹配", evidence: "portrait / comments_summary" },
|
|
|
+ { dimension: "场景贴合", weight: "25%", highScore: "家庭、退休、健康、防骗、养老、祝福场景明确", evidence: "title / tags / body_text" },
|
|
|
+ { dimension: "表达友好", weight: "20%", highScore: "标题直白、字幕大、口播慢、步骤清楚", evidence: "ocr_text / video_text" },
|
|
|
+ { dimension: "情绪安全", weight: "15%", highScore: "共情但不恐吓、不制造家庭对立", evidence: "title / body_text" },
|
|
|
+ { dimension: "转发价值", weight: "10%", highScore: "适合发给父母、亲友或社区群", evidence: "share_intent / comment_terms" },
|
|
|
+ ],
|
|
|
+ passLine: "75+ 适配;60-74 观察;<60 降权或淘汰",
|
|
|
+ thresholds: [
|
|
|
+ { label: "强适配", value: "画像或评论证据强,且表达友好" },
|
|
|
+ { label: "弱适配", value: "场景相关但语气或可读性一般" },
|
|
|
+ { label: "不适配", value: "人群、语境、表达方式都偏离老年用户" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "强适配", value: "提高排序,允许进入作者扩展" },
|
|
|
+ { label: "弱适配", value: "只保留视频,不主动扩作者" },
|
|
|
+ { label: "不适配", value: "淘汰或作为负样本记录" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "audience_fit", value: "strong / weak / mismatch" },
|
|
|
+ { label: "score", value: "0-100,人群适配分单独保留" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "画像字段", value: "50+ ratio / TGI / audience_tags" },
|
|
|
+ { label: "内容字段", value: "title / body_text / ocr_text / comments_summary" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "候选", value: "给爸妈看的反诈提醒:这 4 类电话不要接" },
|
|
|
+ { label: "结论", value: "老年场景和转发价值强,人群适配 91" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: `${profile}-recall-risk-trust`,
|
|
|
+ group: "initialRecall",
|
|
|
+ title: "风险可信规则",
|
|
|
+ summary: "老年赛道先控医疗、理财、政策、私域和恐吓式内容风险。",
|
|
|
+ appliesTo: demandObject,
|
|
|
+ scoreLogic: "硬风险过滤 + 可信度评分",
|
|
|
+ signals: ["医疗功效词", "理财收益词", "政策绝对化", "私域引流", "恐吓标题", "来源可信度"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "医疗承诺", value: "治愈、根治、替代治疗等承诺直接淘汰" },
|
|
|
+ { label: "理财承诺", value: "保本、高收益、养老金套利等表达直接淘汰" },
|
|
|
+ { label: "私域诱导", value: "引导加群、领资料、咨询老师且风险高时停止" },
|
|
|
+ { label: "虚假政策", value: "政策解读无来源且绝对化时淘汰" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "风险低", weight: "35%", highScore: "无医疗、理财、政策误导和强导流", evidence: "risk_terms / title / body_text" },
|
|
|
+ { dimension: "来源可信", weight: "25%", highScore: "账号身份、信息来源或官方线索可解释", evidence: "author_profile / source_url" },
|
|
|
+ { dimension: "表述克制", weight: "20%", highScore: "提示型、科普型表达,不恐吓不煽动", evidence: "title / video_text" },
|
|
|
+ { dimension: "评论反馈", weight: "10%", highScore: "评论无明显投诉、质疑或被骗反馈", evidence: "comments_summary" },
|
|
|
+ { dimension: "平台合规", weight: "10%", highScore: "无敏感词和平台风险标签", evidence: "platform_risk_tags" },
|
|
|
+ ],
|
|
|
+ passLine: "无硬风险且可信度 >= 80 才允许扩作者;60-79 只观察",
|
|
|
+ thresholds: [
|
|
|
+ { label: "低风险", value: "无硬门槛,可信度 >= 80" },
|
|
|
+ { label: "中风险", value: "无硬门槛但存在夸张或来源不明" },
|
|
|
+ { label: "高风险", value: "任一硬门槛命中" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "低风险", value: "继续参与排序和游走" },
|
|
|
+ { label: "中风险", value: "降权,只保留审查证据" },
|
|
|
+ { label: "高风险", value: "淘汰并停止作者扩展" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "risk_level", value: "low / medium / high" },
|
|
|
+ { label: "trust_score", value: "0-100" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "风险字段", value: "title / body_text / video_text / author_signature / comments" },
|
|
|
+ { label: "风险类型", value: "医疗、理财、政策、私域、恐吓、版权" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "候选", value: "养老金新规解读,提醒老人别点陌生链接" },
|
|
|
+ { label: "结论", value: "政策表达需来源校验;无私域诱导,可信度 78,观察" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: `${profile}-recall-content-quality`,
|
|
|
+ group: "initialRecall",
|
|
|
+ title: "内容质量规则",
|
|
|
+ summary: "判断视频是否有清晰结构、信息密度和可复用表达。",
|
|
|
+ appliesTo: "入池候选视频",
|
|
|
+ scoreLogic: "内容质量评分",
|
|
|
+ signals: ["开头钩子", "信息结构", "字幕清晰度", "案例具体性", "行动建议", "完播/互动"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "纯标题党", value: "标题强刺激但正文无信息时淘汰" },
|
|
|
+ { label: "搬运拼接", value: "低质搬运、重复素材、音画严重不一致时淘汰" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "结构清楚", weight: "25%", highScore: "开头、展开、结论完整", evidence: "video_text / body_text" },
|
|
|
+ { dimension: "信息有用", weight: "25%", highScore: "能给老人或子女具体提醒、步骤或判断方法", evidence: "content_summary" },
|
|
|
+ { dimension: "表达可读", weight: "20%", highScore: "字幕清楚、口播稳定、画面不乱", evidence: "ocr_text / video_meta" },
|
|
|
+ { dimension: "案例具体", weight: "15%", highScore: "有真实场景、对象和后果,不泛泛鸡汤", evidence: "title / body_text" },
|
|
|
+ { dimension: "互动表现", weight: "15%", highScore: "收藏、评论、分享与内容价值一致", evidence: "metrics / comments_summary" },
|
|
|
+ ],
|
|
|
+ passLine: "80+ 优质;65-79 可用;<65 不沉淀",
|
|
|
+ thresholds: [
|
|
|
+ { label: "优质", value: "结构和信息价值都强,可作为样板" },
|
|
|
+ { label: "可用", value: "相关但结构一般,只保留候选" },
|
|
|
+ { label: "低质", value: "缺信息或表达混乱,淘汰" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "优质", value: "候选入池,可沉淀内容结构" },
|
|
|
+ { label: "可用", value: "候选入池,不作为结构样板" },
|
|
|
+ { label: "低质", value: "淘汰并记录低质原因" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "quality_level", value: "sample / usable / weak" },
|
|
|
+ { label: "quality_score", value: "0-100" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "结构字段", value: "title / body_text / ocr_text / video_text / metrics" },
|
|
|
+ { label: "质量证据", value: "钩子、步骤、案例、字幕、行动建议" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "候选", value: "独居老人如何防摔:家里这 5 个地方先改" },
|
|
|
+ { label: "结论", value: "结构清楚、建议具体,质量 88,可沉淀样板" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: `${profile}-recall-engagement`,
|
|
|
+ group: "initialRecall",
|
|
|
+ title: "互动质量规则",
|
|
|
+ summary: "区分普通热度和目标人群有效互动,避免只按点赞排序。",
|
|
|
+ appliesTo: "入池候选视频",
|
|
|
+ scoreLogic: "互动有效性评分",
|
|
|
+ signals: ["播放/点赞", "收藏", "评论语义", "转发倾向", "目标人群词", "异常互动"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "疑似刷量", value: "互动结构异常且评论稀薄时降权" },
|
|
|
+ { label: "负反馈集中", value: "评论集中质疑虚假、骗人、标题党时淘汰或观察" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "收藏价值", weight: "25%", highScore: "收藏/点赞相对高,内容有实用性", evidence: "collect_count / like_count" },
|
|
|
+ { dimension: "转发倾向", weight: "20%", highScore: "评论出现发给爸妈、家人、群里等语义", evidence: "comments_summary" },
|
|
|
+ { dimension: "目标评论", weight: "25%", highScore: "爸妈、退休、养老金、老人、防骗等词密集", evidence: "comment_terms" },
|
|
|
+ { dimension: "互动稳定", weight: "15%", highScore: "点赞、收藏、评论比例自然", evidence: "metrics" },
|
|
|
+ { dimension: "负反馈低", weight: "15%", highScore: "投诉、质疑、反感评论少", evidence: "negative_comments" },
|
|
|
+ ],
|
|
|
+ passLine: "有效互动 >= 75 提升排序;<55 不因热度入池",
|
|
|
+ thresholds: [
|
|
|
+ { label: "有效热", value: "互动与目标人群语义一致" },
|
|
|
+ { label: "泛热", value: "数据高但目标语义弱" },
|
|
|
+ { label: "异常", value: "互动结构异常或负反馈明显" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "有效热", value: "提高候选排序,允许扩作者" },
|
|
|
+ { label: "泛热", value: "不加权,只看内容质量" },
|
|
|
+ { label: "异常", value: "降权或淘汰" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "engagement_type", value: "target_hot / generic_hot / abnormal" },
|
|
|
+ { label: "engagement_score", value: "0-100" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "互动字段", value: "like / collect / comment / share / play" },
|
|
|
+ { label: "评论字段", value: "comments_summary / comment_terms / negative_comments" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "候选", value: "评论区大量出现“转给爸妈看看”“老人别被骗”" },
|
|
|
+ { label: "结论", value: "目标互动强,互动有效性 84" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: `${profile}-recall-diversity`,
|
|
|
+ group: "initialRecall",
|
|
|
+ title: "去重疲劳规则",
|
|
|
+ summary: "控制同作者、同模板、同标题和同表达过密,保留代表项。",
|
|
|
+ appliesTo: "准入候选池",
|
|
|
+ scoreLogic: "重复过滤 + 多样性评分",
|
|
|
+ signals: ["content_id", "author_id", "标题相似度", "模板相似度", "话题疲劳", "历史入选"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "内容重复", value: "同 content_id 或镜像搬运直接合并" },
|
|
|
+ { label: "作者过密", value: "同作者同主题占比过高时限制入池数量" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "唯一性", weight: "30%", highScore: "标题、内容、封面都不重复", evidence: "content_id / title_hash / cover_hash" },
|
|
|
+ { dimension: "作者分散", weight: "20%", highScore: "候选池作者分布不过密", evidence: "author_id / author_count" },
|
|
|
+ { dimension: "表达差异", weight: "20%", highScore: "同主题下表达角度不同", evidence: "topic / title / summary" },
|
|
|
+ { dimension: "赛道疲劳", weight: "15%", highScore: "不是近期高频疲劳模板", evidence: "history_selected / reject_reason" },
|
|
|
+ { dimension: "代表价值", weight: "15%", highScore: "重复组里质量和适配最高", evidence: "quality_score / audience_fit" },
|
|
|
+ ],
|
|
|
+ passLine: "重复组只保留代表项;多样性 <60 降权",
|
|
|
+ thresholds: [
|
|
|
+ { label: "保留", value: "唯一或代表价值最高" },
|
|
|
+ { label: "合并", value: "同模板同作者,挂到代表项证据下" },
|
|
|
+ { label: "降权", value: "主题疲劳但仍有补充价值" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "保留", value: "作为独立候选继续评估" },
|
|
|
+ { label: "合并", value: "不占候选名额,保留重复证据" },
|
|
|
+ { label: "降权", value: "减少排序权重和游走预算" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "dedup_action", value: "keep / merge / downrank" },
|
|
|
+ { label: "diversity_score", value: "0-100" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "去重键", value: "content_id / author_id / normalized_title / template_hash" },
|
|
|
+ { label: "疲劳证据", value: "history_selected / topic_frequency / similar_count" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "候选", value: "同作者连续 6 条养老金标题模板高度相似" },
|
|
|
+ { label: "结论", value: "保留质量最高代表项,其余合并" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "walk",
|
|
|
+ title: "游走规则",
|
|
|
+ summary: "视频入池后,再决定作者、相关搜索、热点修饰和预算是否继续走。",
|
|
|
+ rules: [
|
|
|
+ {
|
|
|
+ id: `${profile}-walk-author-supply`,
|
|
|
+ group: "walk",
|
|
|
+ title: "作者供给评估规则",
|
|
|
+ summary: "判断作者是不是稳定、低风险、可复用的老年视频供给源。",
|
|
|
+ appliesTo: "视频作者 / 历史作者",
|
|
|
+ scoreLogic: "作者分层评分",
|
|
|
+ signals: ["作者画像", "作者标签", "近期作品", "历史入选", "风险账号", "供给数量"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "风险账号", value: "医疗导流、理财导流、搬运号、私域强引流直接停止扩展" },
|
|
|
+ { label: "方向不稳定", value: "近期作品连续偏离老年赛道时不扩作者" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "画像适配", weight: "25%", highScore: "50+ 占比/TGI 或评论人群强匹配", evidence: "author_portrait / comments" },
|
|
|
+ { dimension: "内容稳定", weight: "25%", highScore: "长期围绕退休、健康、防骗、家庭、祝福等主题", evidence: "author_tags / recent_titles" },
|
|
|
+ { dimension: "近期质量", weight: "20%", highScore: "近期作品相关且互动稳定", evidence: "recent_works / metrics" },
|
|
|
+ { dimension: "供给潜力", weight: "15%", highScore: "可拉取作品充足,重复率可控", evidence: "work_count / duplicate_rate" },
|
|
|
+ { dimension: "风险低", weight: "15%", highScore: "无导流、夸张、版权和账号风险", evidence: "risk_tags / signature" },
|
|
|
+ ],
|
|
|
+ passLine: "S:85+ 扩 30-50 条;A:70-84 扩 20 条;B:55-69 观察;C:<55 不扩",
|
|
|
+ thresholds: [
|
|
|
+ { label: "S/A 作者", value: "稳定供给,可进入作者扩展队列" },
|
|
|
+ { label: "B 作者", value: "保留观察,只拉少量近期作品" },
|
|
|
+ { label: "C 作者", value: "不扩展,必要时淘汰作者线索" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "S", value: "高预算拉取近期和高互动作品" },
|
|
|
+ { label: "A", value: "中预算拉取近期作品" },
|
|
|
+ { label: "B", value: "低预算观察,不沉淀强资产" },
|
|
|
+ { label: "C", value: "停止扩展" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "author_tier", value: "S / A / B / C" },
|
|
|
+ { label: "expand_budget", value: "0 / 10 / 20 / 30-50" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "作者字段", value: "author_id / sec_uid / nickname / tags / portrait / signature" },
|
|
|
+ { label: "作品字段", value: "recent_works / metrics / selected_history / risk_tags" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "作者", value: "长期发布老年防骗和退休生活提醒,评论区目标人群稳定" },
|
|
|
+ { label: "结论", value: "作者 A 级,扩 20 条近期作品" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: `${profile}-walk-video-author-joint`,
|
|
|
+ group: "walk",
|
|
|
+ title: "视频作者联合决策规则",
|
|
|
+ summary: "视频和作者分别打分,再合并成入池和扩作者动作。",
|
|
|
+ appliesTo: "已评估视频 + 作者",
|
|
|
+ scoreLogic: "双对象决策矩阵",
|
|
|
+ signals: ["视频准入分", "作者分层", "风险等级", "人群适配", "内容稳定性", "扩展预算"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "任一高风险", value: "视频或作者命中高风险,停止作者扩展" },
|
|
|
+ { label: "作者不可访问", value: "作者主页不可访问或作品接口失败,只保留视频判断" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "视频强度", weight: "40%", highScore: "视频准入、人群适配、质量均高", evidence: "video_score / quality_score" },
|
|
|
+ { dimension: "作者强度", weight: "35%", highScore: "作者画像、稳定性、近期质量强", evidence: "author_tier / author_score" },
|
|
|
+ { dimension: "风险一致", weight: "15%", highScore: "视频和作者都低风险", evidence: "risk_level / author_risk" },
|
|
|
+ { dimension: "供给收益", weight: "10%", highScore: "扩展后预计有效作品足够", evidence: "work_count / valid_ratio" },
|
|
|
+ ],
|
|
|
+ passLine: "视频 80+ 入池;作者 70+ 才扩;任一高风险停止扩展",
|
|
|
+ thresholds: [
|
|
|
+ { label: "视频强 + 作者强", value: "视频入池,作者继续扩展" },
|
|
|
+ { label: "视频强 + 作者弱", value: "只保留视频,不扩作者" },
|
|
|
+ { label: "视频弱 + 作者强", value: "淘汰视频,作者保留观察或拉少量作品" },
|
|
|
+ { label: "视频弱 + 作者弱", value: "全部淘汰" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "双强", value: "视频入候选池,作者进入扩展队列" },
|
|
|
+ { label: "视频强", value: "视频入池,作者不沉淀" },
|
|
|
+ { label: "作者强", value: "视频淘汰,作者低预算观察" },
|
|
|
+ { label: "双弱", value: "关闭路径" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "joint_decision", value: "keep_video_expand_author / keep_video_only / observe_author / reject_all" },
|
|
|
+ { label: "reason", value: "保留视频、扩作者或停止的可追溯原因" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "视频证据", value: "video_score / audience_fit / risk_level / quality_score" },
|
|
|
+ { label: "作者证据", value: "author_score / author_tier / author_risk / work_valid_ratio" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "场景", value: "防骗视频强,但作者主页大量理财导流" },
|
|
|
+ { label: "结论", value: "视频观察,作者不扩展" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: `${profile}-walk-related-search`,
|
|
|
+ group: "walk",
|
|
|
+ title: "搜索建议二跳规则",
|
|
|
+ summary: "判断平台相关搜索词是否仍围绕老年需求,而不是漂移到泛兴趣。",
|
|
|
+ appliesTo: "相关搜索二跳",
|
|
|
+ scoreLogic: "二跳相关性评分",
|
|
|
+ signals: ["相关搜索词", "原始 query", seedName, "老年场景词", "命中候选数", "跑偏词"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "无法回扣", value: "相关搜索词无法解释回原始需求或 seed,停止" },
|
|
|
+ { label: "风险放大", value: "二跳词引向医疗、理财、私域风险,停止" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "回扣强度", weight: "35%", highScore: "二跳词能解释原始需求的细分方向", evidence: "related_query / seed_terms" },
|
|
|
+ { dimension: "老年适配", weight: "25%", highScore: "二跳词保留老人、退休、家庭、防骗等语义", evidence: "related_query / tags" },
|
|
|
+ { dimension: "平台可搜", weight: "15%", highScore: "能真实召回有效视频", evidence: "result_count / code" },
|
|
|
+ { dimension: "新增价值", weight: "15%", highScore: "能带来新角度,不只是重复词", evidence: "new_candidate_ratio" },
|
|
|
+ { dimension: "低风险", weight: "10%", highScore: "不引入高风险主题", evidence: "risk_terms" },
|
|
|
+ ],
|
|
|
+ passLine: "75+ 继续;60-74 小预算;<60 停止",
|
|
|
+ thresholds: [
|
|
|
+ { label: "继续", value: "回扣强、可搜、有新增候选" },
|
|
|
+ { label: "小预算", value: "相关但泛化,只取少量候选验证" },
|
|
|
+ { label: "停止", value: "漂移或风险放大" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "继续", value: "生成二跳 query,进入平台搜索" },
|
|
|
+ { label: "小预算", value: "限制 cursor 和候选数量" },
|
|
|
+ { label: "停止", value: "记录二跳停止原因" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "walk_action", value: "continue / small_budget / stop" },
|
|
|
+ { label: "walk_score", value: "0-100" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "二跳字段", value: "related_query / source_video_id / seed_ids / result_count" },
|
|
|
+ { label: "停止证据", value: "drift_terms / risk_terms / duplicate_ratio" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "二跳词", value: "老年人防骗电话提醒" },
|
|
|
+ { label: "结论", value: "能回扣防骗需求,二跳 82,继续" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: `${profile}-walk-tag-topic-callback`,
|
|
|
+ group: "walk",
|
|
|
+ title: "标签话题回扣规则",
|
|
|
+ summary: "判断标签、话题或文本抽取词是否还能解释回原始 seed,而不是变成泛兴趣扩散。",
|
|
|
+ appliesTo: "视频标签 / 笔记话题 / 文本抽取词",
|
|
|
+ scoreLogic: "标签回扣 + 小预算策略",
|
|
|
+ signals: ["标题/desc", "话题词", "标签词", seedName, "召回结果", "漂移词"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "不可回扣", value: "标签或话题无法解释回原始 seed 时停止" },
|
|
|
+ { label: "接口未验证", value: "结构化标签/话题接口未验证时只能作为待验证线索" },
|
|
|
+ { label: "风险放大", value: "标签引向医疗、理财、私域或恐吓内容时停止" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "语义回扣", weight: "35%", highScore: "标签能直接解释需求对象和目的", evidence: "tag_text / seed_terms" },
|
|
|
+ { dimension: "平台可搜", weight: "20%", highScore: "标签能转成平台真实搜索词", evidence: "result_count / query" },
|
|
|
+ { dimension: "新增价值", weight: "20%", highScore: "带来新表达而不是重复原标题", evidence: "new_candidate_ratio" },
|
|
|
+ { dimension: "低漂移", weight: "15%", highScore: "二跳结果仍围绕当前赛道", evidence: "drift_terms / title" },
|
|
|
+ { dimension: "低风险", weight: "10%", highScore: "不引入敏感或误导方向", evidence: "risk_terms" },
|
|
|
+ ],
|
|
|
+ passLine: "75+ 继续;60-74 小预算;接口未验证时不能升级为已验证路径",
|
|
|
+ thresholds: [
|
|
|
+ { label: "继续", value: "语义回扣强、可搜且接口已验证" },
|
|
|
+ { label: "小预算", value: "回扣中等或接口待验证,只拉少量候选" },
|
|
|
+ { label: "停止", value: "泛化、跑偏、风险或接口不可用" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "继续", value: "生成标签/话题 query,进入平台搜索" },
|
|
|
+ { label: "小预算", value: "限制 cursor、候选数和下一跳深度" },
|
|
|
+ { label: "停止", value: "记录标签/话题停止原因" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "topic_action", value: "continue / small_budget / stop" },
|
|
|
+ { label: "evidence_status", value: "verified / pending" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "可用证据", value: "title / desc / body_text / visible_tags" },
|
|
|
+ { label: "待验证证据", value: "topic_id / tag_page / same_topic_posts" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "标签", value: "#肿眼泡 #内双 #眼妆" },
|
|
|
+ { label: "结论", value: "desc 文本可读,但结构化 tag API 待验证,小预算观察" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: `${profile}-walk-author-work`,
|
|
|
+ group: "walk",
|
|
|
+ title: "作者作品二跳规则",
|
|
|
+ summary: "拉取作者作品后,逐条判断作品是否入池,并决定是否继续扩作者。",
|
|
|
+ appliesTo: "作者近期作品",
|
|
|
+ scoreLogic: "作品二次评分",
|
|
|
+ signals: ["作者层级", "作品标题", "作品主题", "发布时间", "互动表现", "重复率"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "连续跑偏", value: "连续多条作品与老年需求无关,停止作者路径" },
|
|
|
+ { label: "风险作品", value: "近期作品集中出现高风险,作者降级或停止" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "作品相关", weight: "30%", highScore: "近期作品持续围绕同类老年场景", evidence: "recent_titles / tags" },
|
|
|
+ { dimension: "质量稳定", weight: "20%", highScore: "多条作品结构清楚、互动稳定", evidence: "metrics / quality_score" },
|
|
|
+ { dimension: "供给新鲜", weight: "15%", highScore: "近期有新作品且不是重复搬运", evidence: "publish_time / duplicate_rate" },
|
|
|
+ { dimension: "作者一致", weight: "20%", highScore: "作品方向与作者标签、画像一致", evidence: "author_tags / portrait" },
|
|
|
+ { dimension: "风险低", weight: "15%", highScore: "近期无明显风险内容", evidence: "risk_tags" },
|
|
|
+ ],
|
|
|
+ passLine: "单作品 75+ 入池;作者作品有效率 >= 40% 才继续扩",
|
|
|
+ thresholds: [
|
|
|
+ { label: "作品入池", value: "单条作品评分 >= 75" },
|
|
|
+ { label: "作者继续", value: "近期作品有效率 >= 40%" },
|
|
|
+ { label: "作者停止", value: "连续低质或有效率 < 20%" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "入池", value: "作品进入候选池并归因到作者路径" },
|
|
|
+ { label: "继续扩", value: "作者保留扩展预算" },
|
|
|
+ { label: "停止", value: "关闭作者路径,保留停止原因" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "work_decision", value: "accept_work / skip_work / stop_author" },
|
|
|
+ { label: "author_valid_ratio", value: "作者作品有效率" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "作品字段", value: "aweme_id / title / publish_time / metrics / tags" },
|
|
|
+ { label: "作者字段", value: "author_id / author_tier / recent_valid_ratio" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "作品", value: "作者近 20 条里 9 条是防骗、养老金、独居老人提醒" },
|
|
|
+ { label: "结论", value: "有效率 45%,继续扩作者" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: `${profile}-walk-hotspot-modifier`,
|
|
|
+ group: "walk",
|
|
|
+ title: "热点修饰规则",
|
|
|
+ summary: "热点只作为 seed 修饰和小预算探索,不作为主质量依据。",
|
|
|
+ appliesTo: "热点词 / 节点词",
|
|
|
+ scoreLogic: "时效修饰评分",
|
|
|
+ signals: ["热点标题", "榜单来源", "热度", "节日节点", "老年相关性", "风险词"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "热点无关", value: "不能修饰当前需求或 seed,不能使用" },
|
|
|
+ { label: "热点风险", value: "医疗、理财、灾害、政策谣言等风险热点不进入主链路" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "需求相关", weight: "30%", highScore: "热点能自然修饰原需求", evidence: "hot_title / seed_terms" },
|
|
|
+ { dimension: "老年相关", weight: "25%", highScore: "节气、养老、防骗、家庭、健康等场景明确", evidence: "feature_keywords" },
|
|
|
+ { dimension: "时效价值", weight: "20%", highScore: "近期热度高且适合短期探索", evidence: "heat / source / time" },
|
|
|
+ { dimension: "平台可搜", weight: "15%", highScore: "修饰后能召回内容", evidence: "search_result_count" },
|
|
|
+ { dimension: "低风险", weight: "10%", highScore: "不会放大敏感或误导表达", evidence: "risk_terms" },
|
|
|
+ ],
|
|
|
+ passLine: "80+ 才用于小预算探索;不替代主 seed",
|
|
|
+ thresholds: [
|
|
|
+ { label: "可修饰", value: "相关、低风险、时效强" },
|
|
|
+ { label: "只记录", value: "有热度但与需求弱相关" },
|
|
|
+ { label: "禁用", value: "风险高或无法解释回 seed" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "小预算探索", value: "生成热点修饰 query,限制候选量" },
|
|
|
+ { label: "只记录", value: "不进入召回,仅作为背景趋势" },
|
|
|
+ { label: "禁用", value: "不生成 query" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "hotspot_action", value: "modify_seed / record_only / forbid" },
|
|
|
+ { label: "budget", value: "热点探索预算,不挤占主通道" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "热点字段", value: "source / title / heat / feature_keywords / jump_url" },
|
|
|
+ { label: "修饰字段", value: "base_seed / modified_query / recall_channel" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "热点", value: "春节返乡防骗提醒" },
|
|
|
+ { label: "结论", value: "可修饰防骗 seed,小预算探索" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: `${profile}-walk-coauthor-trust`,
|
|
|
+ group: "walk",
|
|
|
+ title: "共创关系可信规则",
|
|
|
+ summary: "判断共创视频和共创作者是否可解释、可追溯,避免把弱关系扩成噪声图。",
|
|
|
+ appliesTo: "共创视频 / 共创作者",
|
|
|
+ scoreLogic: "关系可信评分",
|
|
|
+ signals: ["共创关系字段", "共创视频主题", "共创作者主页", "关系来源", "风险", "seed 回扣"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "关系字段缺失", value: "没有稳定共创字段或来源时默认停止" },
|
|
|
+ { label: "无法回扣", value: "共创内容不能解释回原始需求或 seed 时停止" },
|
|
|
+ { label: "作者方向不稳", value: "共创作者作品方向明显漂移时不扩展" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "关系明确", weight: "30%", highScore: "共创关系来源清楚、字段稳定", evidence: "coauthor_id / source" },
|
|
|
+ { dimension: "内容相关", weight: "25%", highScore: "共创视频仍服务当前需求", evidence: "co_video_title / seed_terms" },
|
|
|
+ { dimension: "作者稳定", weight: "20%", highScore: "共创作者近期作品方向稳定", evidence: "author_works / author_tags" },
|
|
|
+ { dimension: "新增供给", weight: "15%", highScore: "能带来新作者而非重复关系", evidence: "new_author_ratio" },
|
|
|
+ { dimension: "风险低", weight: "10%", highScore: "无导流、搬运或敏感风险", evidence: "risk_tags" },
|
|
|
+ ],
|
|
|
+ passLine: "接口和关系字段未验证前默认 P2 停止;验证后 80+ 才小预算扩展",
|
|
|
+ thresholds: [
|
|
|
+ { label: "待验证", value: "无稳定接口时只作为产品线索" },
|
|
|
+ { label: "小预算", value: "关系可信且内容相关时低预算扩" },
|
|
|
+ { label: "停止", value: "关系不可解释、跑偏或高风险" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "待验证", value: "列入接口验证清单,不消耗默认预算" },
|
|
|
+ { label: "小预算", value: "验证后只拉少量共创作者作品" },
|
|
|
+ { label: "停止", value: "关闭共创路径" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "coauthor_action", value: "pending / small_budget / stop" },
|
|
|
+ { label: "relation_reason", value: "共创关系可信或停止原因" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "待验证字段", value: "co_video_id / coauthor_id / relation_source / coauthor_works" },
|
|
|
+ { label: "边界", value: "当前不写成已接入能力" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "路径", value: "强相关视频 -> 共创作者" },
|
|
|
+ { label: "结论", value: "当前无稳定字段,P2 产品占位,默认停止" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: `${profile}-walk-similar-author-trust`,
|
|
|
+ group: "walk",
|
|
|
+ title: "相似作者可信规则",
|
|
|
+ summary: "判断相似作者关系是否有可解释来源,避免推荐关系导致噪声扩散。",
|
|
|
+ appliesTo: "相似作者 / 相似作者作品",
|
|
|
+ scoreLogic: "相似关系评分",
|
|
|
+ signals: ["相似来源", "作者标签", "作者作品", "人群画像", "重复风险", "seed 回扣"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "相似来源不可解释", value: "没有相似关系来源和原因时默认停止" },
|
|
|
+ { label: "作品无关", value: "相似作者近期作品无法回扣当前需求时停止" },
|
|
|
+ { label: "重复过高", value: "同模板、同搬运、同作者群过密时停止或降权" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "相似可解释", weight: "30%", highScore: "能说明为什么相似:标签、受众、作品主题或历史表现", evidence: "similar_reason / tags" },
|
|
|
+ { dimension: "作品稳定", weight: "25%", highScore: "相似作者近期作品持续相关", evidence: "recent_works / valid_ratio" },
|
|
|
+ { dimension: "人群适配", weight: "20%", highScore: "50+ 画像或内容场景适配", evidence: "portrait / content_terms" },
|
|
|
+ { dimension: "新增供给", weight: "15%", highScore: "不是已知作者或重复模板", evidence: "author_id / duplicate_rate" },
|
|
|
+ { dimension: "风险低", weight: "10%", highScore: "无账号风险和内容风险", evidence: "risk_tags" },
|
|
|
+ ],
|
|
|
+ passLine: "接口验证前默认 P2 停止;验证后 80+ 小预算观察",
|
|
|
+ thresholds: [
|
|
|
+ { label: "待验证", value: "相似接口、相似原因未锁时不进入默认游走" },
|
|
|
+ { label: "观察", value: "相似原因清楚但历史不足,低预算观察" },
|
|
|
+ { label: "停止", value: "不可解释、跑偏、重复或高风险" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "待验证", value: "进入验证清单" },
|
|
|
+ { label: "观察", value: "验证后拉少量作品评估作者" },
|
|
|
+ { label: "停止", value: "关闭相似作者路径" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "similar_action", value: "pending / observe / stop" },
|
|
|
+ { label: "similar_reason", value: "相似关系来源和可解释原因" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "待验证字段", value: "similar_author_id / similar_reason / author_works / portrait" },
|
|
|
+ { label: "边界", value: "不使用白板自由连线替代事实记录" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "路径", value: "历史作者 -> 相似作者 -> 相似作者作品" },
|
|
|
+ { label: "结论", value: "相似来源未验证,P2 停止" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: `${profile}-walk-drift-stop`,
|
|
|
+ group: "walk",
|
|
|
+ title: "漂移停止规则",
|
|
|
+ summary: "判断二跳、三跳是否已经脱离原始方向,防止标签、相关搜索和相似关系越走越远。",
|
|
|
+ appliesTo: "所有二跳 / 三跳路径",
|
|
|
+ scoreLogic: "漂移检测 + 停止决策",
|
|
|
+ signals: ["原始 seed", "上一跳节点", "目标对象", "标题/标签", "风险词", "重复率"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "目的漂移", value: "目标对象无法解释原始目的点或业务目标时停止" },
|
|
|
+ { label: "对象漂移", value: "内容对象从目标赛道转向泛兴趣、泛商品或泛娱乐时停止" },
|
|
|
+ { label: "风险漂移", value: "二跳引入医疗、理财、私域等高风险时停止" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "目的一致", weight: "35%", highScore: "仍服务原始目的点", evidence: "purpose_point / node_title" },
|
|
|
+ { dimension: "对象一致", weight: "25%", highScore: "核心对象不偏离", evidence: "subject_terms / tags" },
|
|
|
+ { dimension: "语境一致", weight: "20%", highScore: "平台语境和目标人群仍匹配", evidence: "platform_terms / audience_fit" },
|
|
|
+ { dimension: "新增有效", weight: "10%", highScore: "新增内容不是重复或噪声", evidence: "new_valid_count" },
|
|
|
+ { dimension: "风险低", weight: "10%", highScore: "无新增风险", evidence: "risk_terms" },
|
|
|
+ ],
|
|
|
+ passLine: "漂移分 <70 停止;70-84 降预算;85+ 可继续",
|
|
|
+ thresholds: [
|
|
|
+ { label: "继续", value: "目的、对象、语境都一致" },
|
|
|
+ { label: "降预算", value: "仍相关但开始泛化" },
|
|
|
+ { label: "停止", value: "目的、对象、风险任一明显漂移" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "继续", value: "保留下一跳预算" },
|
|
|
+ { label: "降预算", value: "限制候选数并要求更高入池阈值" },
|
|
|
+ { label: "停止", value: "关闭路径并记录 stop_reason" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "drift_action", value: "continue / reduce / stop" },
|
|
|
+ { label: "stop_reason", value: "目的漂移 / 对象漂移 / 风险漂移 / 重复漂移" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "对比字段", value: "seed_terms / from_node / to_node / tags / risk_terms" },
|
|
|
+ { label: "沉淀字段", value: "walk_depth / drift_score / stop_reason" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "路径", value: "肿眼泡眼妆 -> 美瞳品牌种草 -> 彩瞳价格" },
|
|
|
+ { label: "结论", value: "对象漂移,停止" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: `${profile}-walk-budget-stop`,
|
|
|
+ group: "walk",
|
|
|
+ title: "预算停止规则",
|
|
|
+ summary: "按层级、有效率、重复率和风险率决定继续、降预算或停止。",
|
|
|
+ appliesTo: "游走路径",
|
|
|
+ scoreLogic: "预算收益评分",
|
|
|
+ signals: ["游走层级", "新增候选数", "有效率", "重复率", "风险率", "接口预算"],
|
|
|
+ hardGates: [
|
|
|
+ { label: "层级上限", value: "达到设定游走层级上限时停止" },
|
|
|
+ { label: "连续低收益", value: "连续两轮有效率过低或风险率过高时停止" },
|
|
|
+ ],
|
|
|
+ scoring: [
|
|
|
+ { dimension: "新增有效", weight: "30%", highScore: "新增候选质量高且不重复", evidence: "new_valid_count / duplicate_count" },
|
|
|
+ { dimension: "收益稳定", weight: "25%", highScore: "有效率持续高于阈值", evidence: "valid_ratio_history" },
|
|
|
+ { dimension: "风险可控", weight: "20%", highScore: "风险候选比例低", evidence: "risk_ratio" },
|
|
|
+ { dimension: "预算健康", weight: "15%", highScore: "接口成本和候选收益匹配", evidence: "fetched_count / cost" },
|
|
|
+ { dimension: "路径可解释", weight: "10%", highScore: "每一跳都能回扣 seed 和 channel", evidence: "walk_trace / seed_ids" },
|
|
|
+ ],
|
|
|
+ passLine: "收益分 75+ 继续;55-74 降预算;<55 停止",
|
|
|
+ thresholds: [
|
|
|
+ { label: "继续", value: "新增有效候选充足,风险和重复可控" },
|
|
|
+ { label: "降预算", value: "仍有少量收益,但重复率或成本上升" },
|
|
|
+ { label: "停止", value: "低收益、漂移、风险或层级上限" },
|
|
|
+ ],
|
|
|
+ actionPolicy: [
|
|
|
+ { label: "继续", value: "保留当前路径,进入下一跳" },
|
|
|
+ { label: "降预算", value: "减少候选数、cursor 或作者作品量" },
|
|
|
+ { label: "停止", value: "关闭路径,进入资产清洗沉淀" },
|
|
|
+ ],
|
|
|
+ outputs: [
|
|
|
+ { label: "budget_action", value: "continue / reduce / stop" },
|
|
|
+ { label: "stop_reason", value: "漂移、重复、风险、低收益或层级上限" },
|
|
|
+ ],
|
|
|
+ evidence: [
|
|
|
+ { label: "预算字段", value: "walk_depth / fetched_count / valid_count / duplicate_count / risk_count" },
|
|
|
+ { label: "归因字段", value: "seed_ids / query / recall_channel / author_id / stop_reason" },
|
|
|
+ ],
|
|
|
+ example: [
|
|
|
+ { label: "路径", value: "第三跳只新增 2 条有效候选,重复率 68%" },
|
|
|
+ { label: "结论", value: "收益分 49,停止并记录重复过高" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ];
|
|
|
+}
|
|
|
+
|
|
|
+const allFilterOption: DataSourceFilterOption = { id: "all", label: "全部" };
|
|
|
+
|
|
|
+const dataSourcePanels: Record<SourceLaneId, DataSourcePanelConfig> = {
|
|
|
+ pattern: {
|
|
|
+ laneId: "pattern",
|
|
|
+ title: "Pattern 原始特征源",
|
|
|
+ sourceObject: "open_aigc_pattern.topic_pattern_element",
|
|
|
+ filterLabel: "Pattern",
|
|
|
+ filterOptions: [allFilterOption],
|
|
|
+ searchLabel: "Pattern",
|
|
|
+ searchPlaceholder: "搜索 name、element_type、execution_id、post_id、category_path",
|
|
|
+ emptyText: "没有匹配的 Pattern 元素",
|
|
|
+ records: [
|
|
|
+ {
|
|
|
+ id: "pattern-56207313-form",
|
|
|
+ title: "关键点_目的点",
|
|
|
+ subtitle: "祝福词句 / 早安问候 / 分享",
|
|
|
+ badge: "Pattern",
|
|
|
+ status: "verified",
|
|
|
+ evidenceStatus: "DB 已验证",
|
|
|
+ filterValue: "all",
|
|
|
+ tags: ["祝福词句", "早安问候", "分享"],
|
|
|
+ metrics: [],
|
|
|
+ patternListCard: {
|
|
|
+ groupTitle: "关键点_目的点",
|
|
|
+ chips: [
|
|
|
+ { label: "祝福词句", meta: "关键点_实质", tone: "red" },
|
|
|
+ { label: "早安问候", meta: "目的点_实质", tone: "red" },
|
|
|
+ { label: "分享", meta: "目的点_意图", tone: "orange" },
|
|
|
+ ],
|
|
|
+ support: "510",
|
|
|
+ ratio: "4.2%",
|
|
|
+ itemCount: "3",
|
|
|
+ },
|
|
|
+ materialRows: [
|
|
|
+ { label: "execution_id", value: "80" },
|
|
|
+ { label: "post_id", value: "56207313" },
|
|
|
+ ],
|
|
|
+ seedRows: [
|
|
|
+ { label: "稳定特征", value: "反差式音画、冲突性、叙事性字幕、拟人化旁白" },
|
|
|
+ { label: "element_type", value: "形式" },
|
|
|
+ { label: "category_path", value: "内容表达 / 叙事形式 / 反差冲突" },
|
|
|
+ ],
|
|
|
+ materialTable: {
|
|
|
+ columns: ["field", "value", "说明"],
|
|
|
+ rows: [
|
|
|
+ ["name", "反差式音画", "Pattern 元素名"],
|
|
|
+ ["point_type", "关键点", "原始点位类型"],
|
|
|
+ ["point_text", "音画反差制造注意力", "原始点文本"],
|
|
|
+ ["element_type", "形式", "元素分类"],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ searchableText: ["关键点_目的点", "祝福词句", "早安问候", "分享", "反差式音画", "冲突性", "叙事性字幕", "拟人化旁白", "形式", "80", "56207313", "category_path"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "pattern-blessing-substance",
|
|
|
+ title: "实质_形式",
|
|
|
+ subtitle: "福报 / 祈福 / 吉祥话",
|
|
|
+ badge: "Pattern",
|
|
|
+ status: "verified",
|
|
|
+ evidenceStatus: "DB 已验证",
|
|
|
+ filterValue: "all",
|
|
|
+ tags: ["element_type=实质", "需求特征"],
|
|
|
+ metrics: [],
|
|
|
+ patternListCard: {
|
|
|
+ groupTitle: "实质_形式",
|
|
|
+ chips: [
|
|
|
+ { label: "福报", meta: "灵感点_实质", tone: "red" },
|
|
|
+ { label: "祈福", meta: "目的点_意图", tone: "orange" },
|
|
|
+ { label: "吉祥话", meta: "关键点_形式", tone: "blue" },
|
|
|
+ ],
|
|
|
+ support: "210",
|
|
|
+ ratio: "1.7%",
|
|
|
+ itemCount: "3",
|
|
|
+ },
|
|
|
+ materialRows: [],
|
|
|
+ seedRows: [
|
|
|
+ { label: "稳定特征", value: "福报、祈福、吉祥话" },
|
|
|
+ { label: "element_type", value: "实质" },
|
|
|
+ { label: "category_path", value: "民俗祝福 / 玄学观念" },
|
|
|
+ ],
|
|
|
+ searchableText: ["福报", "祈福", "吉祥话", "实质", "topic_pattern_element", "workflow_decode_task_result"],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ case: {
|
|
|
+ laneId: "case",
|
|
|
+ title: "Case 历史素材源",
|
|
|
+ sourceObject: "content-deconstruction-supply.workflow_decode_task_result",
|
|
|
+ filterLabel: "平台",
|
|
|
+ filterOptions: [{ id: "piaoquan", label: "票圈" }],
|
|
|
+ searchLabel: "Case",
|
|
|
+ searchPlaceholder: "搜索标题、post_id、账号、平台、目的点/关键点/灵感点",
|
|
|
+ emptyText: "没有匹配的历史 Case",
|
|
|
+ records: [
|
|
|
+ {
|
|
|
+ id: "case-6798cfa1000000001801ab86",
|
|
|
+ title: "内双肿眼泡 快去试这个清透纯欲感眼妆",
|
|
|
+ subtitle: "channel_content_id=6798cfa1000000001801ab86 / 单眼皮倩倩",
|
|
|
+ badge: "Case",
|
|
|
+ status: "verified",
|
|
|
+ evidenceStatus: "静态样例",
|
|
|
+ filterValue: "xhs",
|
|
|
+ tags: ["特征归类", "起点策略", "筛选方案"],
|
|
|
+ metrics: [
|
|
|
+ { label: "赞", value: "96" },
|
|
|
+ { label: "藏", value: "64" },
|
|
|
+ { label: "评", value: "0" },
|
|
|
+ ],
|
|
|
+ materialRows: [],
|
|
|
+ seedRows: [
|
|
|
+ { label: "实质特征", value: "内双肿眼泡、清透纯欲眼妆、眼妆教程、眼影、睫毛、彩妆工具" },
|
|
|
+ { label: "形式特征", value: "图示说明、步骤拆解、文字标注、操作教程、前后对比、局部特写" },
|
|
|
+ { label: "上层特征", value: "眼妆教程、妆容风格、彩妆" },
|
|
|
+ { label: "下层特征", value: "内双肿眼泡、清透纯欲眼妆、粉色系眼影、单簇假睫毛" },
|
|
|
+ ],
|
|
|
+ materialTable: caseDecodeTable,
|
|
|
+ seedTable: {
|
|
|
+ columns: ["老版输出块", "字段", "本 Case 种子", "后续用途"],
|
|
|
+ rows: [
|
|
|
+ ["特征归类", "实质特征", "内双肿眼泡、清透纯欲眼妆、眼妆教程", "作为主题和搜索对象"],
|
|
|
+ ["特征归类", "形式特征", "图示说明、步骤拆解、文字标注、前后对比", "进入筛选规则,不单独做强召回"],
|
|
|
+ ["特征归类", "下层特征", "内双肿眼泡、清透纯欲眼妆", "用于查高赞 case 或直接搜索"],
|
|
|
+ ["起点策略", "高赞case出发搜索词", "肿眼泡眼妆教程、内双眼妆画法、清透感眼妆", "从 case 选题点转译出来"],
|
|
|
+ ["起点策略", "特征出发搜索词", "内双肿眼泡、眼妆教程、清透纯欲眼妆", "以上/下层特征原词为主"],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ searchableText: ["内双肿眼泡", "清透纯欲", "单眼皮倩倩", "小红书", "实质特征", "形式特征", "上层特征", "下层特征", "高赞case出发搜索词"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "case-66036146",
|
|
|
+ title: "700万公职人员要被扒个底朝天了",
|
|
|
+ subtitle: "channel_content_id=66036146 / 扩宽视野看世界",
|
|
|
+ badge: "Case",
|
|
|
+ status: "verified",
|
|
|
+ evidenceStatus: "静态样例",
|
|
|
+ filterValue: "piaoquan",
|
|
|
+ tags: ["特征归类", "起点策略", "票圈"],
|
|
|
+ metrics: [
|
|
|
+ { label: "看", value: "0" },
|
|
|
+ { label: "赞", value: "486" },
|
|
|
+ { label: "评", value: "0" },
|
|
|
+ ],
|
|
|
+ materialRows: [
|
|
|
+ { label: "body_text", value: "悬念式标题、资产类型列举、警示性结尾" },
|
|
|
+ ],
|
|
|
+ seedRows: [
|
|
|
+ { label: "实质特征", value: "公职人员财产核查、资产核查、退休群、公共管理、反腐行动" },
|
|
|
+ { label: "形式特征", value: "悬念式标题、资产类型列举、口播字幕强调、警示性结尾" },
|
|
|
+ { label: "上层特征", value: "公共管理、退休生活、政策提醒" },
|
|
|
+ { label: "下层特征", value: "公职人员财产核查、房产车辆存款核查、退休群提醒" },
|
|
|
+ ],
|
|
|
+ seedTable: {
|
|
|
+ columns: ["老版输出块", "字段", "本 Case 种子", "后续用途"],
|
|
|
+ rows: [
|
|
|
+ ["特征归类", "实质特征", "公职人员财产核查、资产核查、退休群", "作为主题和搜索对象"],
|
|
|
+ ["特征归类", "形式特征", "悬念式标题、资产列举、警示性结尾", "进入筛选规则,不单独做强召回"],
|
|
|
+ ["特征归类", "下层特征", "公职人员财产核查、房产车辆存款核查", "用于查高赞 case 或直接搜索"],
|
|
|
+ ["起点策略", "高赞case出发搜索词", "公职人员财产核查、退休人员资产核查提醒", "从 case 选题点转译出来"],
|
|
|
+ ["起点策略", "特征出发搜索词", "财产核查、退休政策提醒、公职人员资产", "以上/下层特征原词为主"],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ searchableText: ["700万", "公职人员", "财产核查", "退休群", "扩宽视野看世界", "票圈"],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ "historical-search-record": {
|
|
|
+ laneId: "historical-search-record",
|
|
|
+ title: "历史优质搜索记录",
|
|
|
+ sourceObject: "content-deconstruction-supply.demand_find_content_result",
|
|
|
+ filterLabel: "平台",
|
|
|
+ filterOptions: [allFilterOption, { id: "douyin", label: "抖音" }, { id: "piaoquan", label: "票圈" }],
|
|
|
+ searchLabel: "历史搜索",
|
|
|
+ searchPlaceholder: "搜索 query、trace_id、demand_content_id、标题、作者",
|
|
|
+ emptyText: "没有匹配的历史搜索记录",
|
|
|
+ records: [
|
|
|
+ {
|
|
|
+ id: "search-04899899-eye",
|
|
|
+ title: "query=内双肿眼泡眼妆教程",
|
|
|
+ subtitle: "命中内容:折腾无数次才发现,肿眼泡步骤越少眼妆才越好看",
|
|
|
+ badge: "历史 Query",
|
|
|
+ status: "verified",
|
|
|
+ evidenceStatus: "静态样例",
|
|
|
+ filterValue: "douyin",
|
|
|
+ tags: ["历史成功路径", "抖音"],
|
|
|
+ metrics: [
|
|
|
+ { label: "赞", value: "486" },
|
|
|
+ ],
|
|
|
+ materialRows: [
|
|
|
+ { label: "trace_id", value: "04899899-1e1b-453b-8719-919e82ec683d" },
|
|
|
+ { label: "demand_content_id", value: "45419" },
|
|
|
+ { label: "aweme_id", value: "7639700354116783865" },
|
|
|
+ { label: "author_name", value: "毛头小资" },
|
|
|
+ ],
|
|
|
+ seedRows: [
|
|
|
+ { label: "历史 query", value: "内双肿眼泡眼妆教程" },
|
|
|
+ { label: "来源 trace", value: "04899899-1e1b-453b-8719-919e82ec683d" },
|
|
|
+ { label: "命中内容", value: "aweme_id=7639700354116783865" },
|
|
|
+ ],
|
|
|
+ searchableText: ["内双肿眼泡眼妆教程", "04899899", "45419", "毛头小资", "7639700354116783865", "抖音"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "search-allowance-history",
|
|
|
+ title: "query=独生子女补贴",
|
|
|
+ subtitle: "命中内容:独生子女家庭,千万别忘了领这笔补贴",
|
|
|
+ badge: "历史 Query",
|
|
|
+ status: "verified",
|
|
|
+ evidenceStatus: "DB 样本",
|
|
|
+ filterValue: "douyin",
|
|
|
+ tags: ["50+画像", "历史结果", "抖音"],
|
|
|
+ metrics: [
|
|
|
+ { label: "portrait", value: "account_fans" },
|
|
|
+ { label: "50+占比", value: "54.26%" },
|
|
|
+ { label: "TGI", value: "239.22" },
|
|
|
+ ],
|
|
|
+ materialRows: [
|
|
|
+ { label: "aweme_id", value: "7574757080343530758" },
|
|
|
+ ],
|
|
|
+ seedRows: [
|
|
|
+ { label: "历史 query", value: "独生子女补贴" },
|
|
|
+ { label: "命中内容", value: "独生子女家庭,千万别忘了领这笔补贴" },
|
|
|
+ { label: "历史有效信号", value: "50+占比/TGI 已沉淀在结果表" },
|
|
|
+ ],
|
|
|
+ searchableText: ["独生子女补贴", "独生子女家庭", "7574757080343530758", "account_fans", "54.26%", "239.22"],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ "historical-account": {
|
|
|
+ laneId: "historical-account",
|
|
|
+ title: "历史沉淀账号",
|
|
|
+ sourceObject: "content-deconstruction-supply.demand_find_author",
|
|
|
+ filterLabel: "平台",
|
|
|
+ filterOptions: [allFilterOption, { id: "douyin", label: "抖音" }, { id: "xhs", label: "小红书" }],
|
|
|
+ searchLabel: "作者",
|
|
|
+ searchPlaceholder: "搜索作者名、author_id、sec_uid、content_tags、50+画像",
|
|
|
+ emptyText: "没有匹配的历史账号",
|
|
|
+ records: [
|
|
|
+ {
|
|
|
+ id: "author-single-eye",
|
|
|
+ title: "单眼皮倩倩",
|
|
|
+ subtitle: "content_tags=内双眼妆, 肿眼泡, 眼妆教程",
|
|
|
+ badge: "作者资产",
|
|
|
+ status: "verified",
|
|
|
+ evidenceStatus: "静态样例",
|
|
|
+ filterValue: "xhs",
|
|
|
+ tags: ["content_tags", "作者画像", "可复用"],
|
|
|
+ metrics: [
|
|
|
+ { label: "50+占比", value: "待补" },
|
|
|
+ { label: "TGI", value: "待补" },
|
|
|
+ { label: "channel", value: "小红书" },
|
|
|
+ ],
|
|
|
+ materialRows: [
|
|
|
+ { label: "author_id/sec_uid", value: "xhs-author-placeholder" },
|
|
|
+ ],
|
|
|
+ seedRows: [
|
|
|
+ { label: "作者身份", value: "单眼皮倩倩" },
|
|
|
+ { label: "作者标签", value: "内双眼妆、肿眼泡、眼妆教程" },
|
|
|
+ { label: "画像信号", value: "50+占比=待补, TGI=待补" },
|
|
|
+ ],
|
|
|
+ searchableText: ["单眼皮倩倩", "内双眼妆", "肿眼泡", "眼妆教程", "小红书", "author"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "author-welfare-douyin",
|
|
|
+ title: "政策福利讲解号",
|
|
|
+ subtitle: "content_tags=补贴, 福利, 退休政策",
|
|
|
+ badge: "作者资产",
|
|
|
+ status: "verified",
|
|
|
+ evidenceStatus: "DB 样本",
|
|
|
+ filterValue: "douyin",
|
|
|
+ tags: ["douyin_user_videos", "50+"],
|
|
|
+ metrics: [
|
|
|
+ { label: "50+占比", value: "54.26%" },
|
|
|
+ { label: "TGI", value: "239.22" },
|
|
|
+ ],
|
|
|
+ materialRows: [
|
|
|
+ { label: "author_link", value: "https://www.douyin.com/user/{sec_uid}" },
|
|
|
+ { label: "author_id/sec_uid", value: "MS4wLjAB...placeholder" },
|
|
|
+ { label: "trace_id", value: "历史作者沉淀 trace" },
|
|
|
+ ],
|
|
|
+ seedRows: [
|
|
|
+ { label: "作者标签", value: "补贴、福利、退休政策" },
|
|
|
+ { label: "画像信号", value: "50+占比=54.26%, TGI=239.22" },
|
|
|
+ { label: "历史 remark", value: "适合中老年政策福利类内容复用" },
|
|
|
+ ],
|
|
|
+ searchableText: ["政策福利", "补贴", "福利", "退休政策", "54.26", "239.22", "douyin_user_videos", "抖音"],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ hotspot: {
|
|
|
+ laneId: "hotspot",
|
|
|
+ title: "热点与热榜源",
|
|
|
+ sourceObject: "crawapi.jin_ri_re_bang.content_rank",
|
|
|
+ filterLabel: "来源",
|
|
|
+ filterOptions: [allFilterOption, { id: "今日热榜", label: "今日热榜" }, { id: "节日节点", label: "节日节点" }, { id: "平台热点", label: "平台热点" }],
|
|
|
+ searchLabel: "热点",
|
|
|
+ searchPlaceholder: "搜索热点标题、榜单来源、热度、feature_keywords、cursor",
|
|
|
+ emptyText: "没有匹配的热点素材",
|
|
|
+ records: [
|
|
|
+ {
|
|
|
+ id: "hot-topic-public-staff",
|
|
|
+ title: "公职人员财产核查",
|
|
|
+ subtitle: "source=今日热榜 / heat=1280w / matched_keywords=公职人员, 财产核查",
|
|
|
+ badge: "热点",
|
|
|
+ status: "verified",
|
|
|
+ evidenceStatus: "API 已验证",
|
|
|
+ filterValue: "今日热榜",
|
|
|
+ tags: ["matched_keywords", "社会"],
|
|
|
+ metrics: [
|
|
|
+ { label: "heat", value: "1280w" },
|
|
|
+ ],
|
|
|
+ materialRows: [],
|
|
|
+ seedRows: [
|
|
|
+ { label: "热点标题", value: "公职人员财产核查" },
|
|
|
+ { label: "热度", value: "1280w" },
|
|
|
+ { label: "matched_keywords", value: "公职人员、财产核查" },
|
|
|
+ ],
|
|
|
+ searchableText: ["公职人员", "财产核查", "今日热榜", "1280w", "matched_keywords", "cursor"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "hot-topic-blessing-festival",
|
|
|
+ title: "小年祭灶仪式",
|
|
|
+ subtitle: "source=节日节点 / heat=426w / matched_keywords=小年, 祭灶",
|
|
|
+ badge: "热点",
|
|
|
+ status: "verified",
|
|
|
+ evidenceStatus: "API 已验证",
|
|
|
+ filterValue: "节日节点",
|
|
|
+ tags: ["节日", "民俗"],
|
|
|
+ metrics: [
|
|
|
+ { label: "heat", value: "426w" },
|
|
|
+ ],
|
|
|
+ materialRows: [],
|
|
|
+ seedRows: [
|
|
|
+ { label: "热点标题", value: "小年祭灶仪式" },
|
|
|
+ { label: "matched_keywords", value: "小年、祭灶" },
|
|
|
+ ],
|
|
|
+ searchableText: ["小年", "祭灶", "节日节点", "民俗", "426w", "热点"],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ "nurtured-account": {
|
|
|
+ laneId: "nurtured-account",
|
|
|
+ title: "养号推荐流",
|
|
|
+ sourceObject: "产品意图 / 推荐流采集入口",
|
|
|
+ filterLabel: "平台",
|
|
|
+ filterOptions: [allFilterOption, { id: "douyin", label: "抖音" }, { id: "xhs", label: "小红书" }, { id: "kuaishou", label: "快手" }],
|
|
|
+ searchLabel: "推荐流",
|
|
|
+ searchPlaceholder: "搜索平台、养号账号、推荐流主题、作者、标签、采集状态",
|
|
|
+ emptyText: "没有匹配的养号推荐流样例",
|
|
|
+ records: [
|
|
|
+ {
|
|
|
+ id: "nurtured-douyin-eye",
|
|
|
+ title: "抖音养号:中老年眼妆推荐流",
|
|
|
+ subtitle: "platform=抖音 / account=beauty-seed-01 / status=待接入",
|
|
|
+ badge: "养号",
|
|
|
+ status: "placeholder",
|
|
|
+ evidenceStatus: "产品意图",
|
|
|
+ filterValue: "douyin",
|
|
|
+ tags: ["推荐流", "中老年妆容"],
|
|
|
+ metrics: [],
|
|
|
+ materialRows: [
|
|
|
+ { label: "case_link", value: "推荐流 case 链接待采集" },
|
|
|
+ { label: "author", value: "推荐流作者待采集" },
|
|
|
+ { label: "collected_at", value: "待接入采集时间" },
|
|
|
+ ],
|
|
|
+ seedRows: [
|
|
|
+ { label: "推荐流信息", value: "平台长期推荐的眼妆教程方向" },
|
|
|
+ { label: "捕获标签", value: "眼妆教程、中老年妆容、推荐流 case" },
|
|
|
+ ],
|
|
|
+ searchableText: ["抖音", "养号", "beauty-seed-01", "眼妆教程", "中老年妆容", "推荐流", "待接入"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "nurtured-xhs-blessing",
|
|
|
+ title: "小红书养号:民俗祝福推荐流",
|
|
|
+ subtitle: "platform=小红书 / account=folk-seed-02 / status=待接入",
|
|
|
+ badge: "养号",
|
|
|
+ status: "placeholder",
|
|
|
+ evidenceStatus: "产品意图",
|
|
|
+ filterValue: "xhs",
|
|
|
+ tags: ["推荐流", "民俗祝福"],
|
|
|
+ metrics: [],
|
|
|
+ materialRows: [
|
|
|
+ { label: "case_link", value: "推荐流 case 链接待采集" },
|
|
|
+ ],
|
|
|
+ seedRows: [
|
|
|
+ { label: "推荐流信息", value: "平台推荐的民俗祝福内容" },
|
|
|
+ { label: "捕获标签", value: "福报、祈福、吉祥话" },
|
|
|
+ ],
|
|
|
+ searchableText: ["小红书", "养号", "folk-seed-02", "民俗祝福", "福报", "祈福", "吉祥话", "待接入"],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+};
|
|
|
+
|
|
|
+const caseQueryWorkshop: QueryWorkshop = {
|
|
|
+ evidence: [
|
|
|
+ {
|
|
|
+ title: "内双肿眼泡",
|
|
|
+ kind: "下层特征",
|
|
|
+ source: "主种子 / 可直接搜索",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "清透纯欲眼妆",
|
|
|
+ kind: "下层特征",
|
|
|
+ source: "风格种子 / 需绑定主种子",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "粉色系眼影",
|
|
|
+ kind: "下层特征",
|
|
|
+ source: "物品种子 / 只做扩展",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "单簇假睫毛",
|
|
|
+ kind: "下层特征",
|
|
|
+ source: "物品种子 / 只做扩展",
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ prompts: [
|
|
|
+ {
|
|
|
+ title: "下层特征生成 Prompt",
|
|
|
+ content: "只使用 Case 种子里的下层特征,生成第一批可搜索 query。",
|
|
|
+ fullContent: `# 角色
|
|
|
+你是 Content Find Agent 的 Query Builder,负责把 Case 种子中的“下层特征”转成平台可搜索 query。
|
|
|
+
|
|
|
+# 输入
|
|
|
+下层特征:
|
|
|
+- 内双肿眼泡
|
|
|
+- 清透纯欲眼妆
|
|
|
+- 粉色系眼影
|
|
|
+- 单簇假睫毛
|
|
|
+
|
|
|
+平台:抖音
|
|
|
+目标:生成能召回“内双/肿眼泡眼妆教程”类候选内容的搜索 query。
|
|
|
+
|
|
|
+# 任务
|
|
|
+1. 先识别真正的主种子:内双肿眼泡。
|
|
|
+2. 再识别内容类型:眼妆 / 眼妆教程 / 眼妆画法。
|
|
|
+3. 把“清透纯欲眼妆”作为风格修饰,不能覆盖主种子。
|
|
|
+4. 把“粉色系眼影、单簇假睫毛”作为物品扩展,不能单独作为主 query。
|
|
|
+5. 输出主 query、风格扩展 query、物品扩展 query 三组。
|
|
|
+
|
|
|
+# 约束
|
|
|
+1. 每个 query 必须至少命中一个下层特征。
|
|
|
+2. 主 query 必须保留“内双”或“肿眼泡”。
|
|
|
+3. 不使用 Case 标题原句,不使用账号名,不使用目的点/关键点。
|
|
|
+4. 不生成纯产品词,例如只输出“粉色系眼影”或“单簇假睫毛”不合格。
|
|
|
+5. query 控制在 6 到 14 个中文字符,像真实用户会搜的短词。
|
|
|
+
|
|
|
+# 输出格式
|
|
|
+主 query:
|
|
|
+- ...
|
|
|
+
|
|
|
+扩展 query:
|
|
|
+- ...
|
|
|
+- ...`,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "下层特征组合 Prompt",
|
|
|
+ content: "把四个下层特征按主种子、风格种子、物品种子组合成候选 query。",
|
|
|
+ fullContent: `# 角色
|
|
|
+你是 Content Find Agent 的 Query Builder,负责把 Case 下层特征组合成平台可执行 query。
|
|
|
+
|
|
|
+# 输入
|
|
|
+下层特征:
|
|
|
+- 内双肿眼泡
|
|
|
+- 清透纯欲眼妆
|
|
|
+- 粉色系眼影
|
|
|
+- 单簇假睫毛
|
|
|
+
|
|
|
+# 任务
|
|
|
+1. 将下层特征分成三类:
|
|
|
+ - 主种子:内双肿眼泡
|
|
|
+ - 风格种子:清透纯欲眼妆
|
|
|
+ - 物品种子:粉色系眼影、单簇假睫毛
|
|
|
+2. 主 query 优先由“主种子 + 内容类型”组成。
|
|
|
+3. 风格扩展 query 必须绑定主种子或眼妆教程。
|
|
|
+4. 物品扩展 query 必须绑定肿眼泡/内双/眼妆,不能单独召回产品种草。
|
|
|
+5. 每个 query 标注它回扣了哪些下层特征。
|
|
|
+
|
|
|
+# 过滤规则
|
|
|
+1. 没有命中任何下层特征的 query 淘汰。
|
|
|
+2. 只有物品词、没有眼部问题或眼妆语义的 query 淘汰。
|
|
|
+3. 过宽泛词如“美妆教程”“化妆教程”淘汰。
|
|
|
+4. 风格词不能覆盖主种子,例如只输出“纯欲妆教程”要降级或淘汰。
|
|
|
+
|
|
|
+# 输出格式
|
|
|
+[
|
|
|
+ {
|
|
|
+ "query": "...",
|
|
|
+ "类型": "主 query / 风格扩展 / 物品扩展",
|
|
|
+ "回扣下层特征": ["..."]
|
|
|
+ }
|
|
|
+]`,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "平台压缩 Prompt",
|
|
|
+ content: "把下层特征组合出的候选 query 压缩成抖音更容易召回的短词。",
|
|
|
+ fullContent: `# 角色
|
|
|
+你是抖音搜索改写模块,负责把候选 query 压缩成抖音更容易搜索的表达。
|
|
|
+
|
|
|
+# 输入
|
|
|
+候选 query 来自 Case 下层特征组合:
|
|
|
+- 内双肿眼泡
|
|
|
+- 清透纯欲眼妆
|
|
|
+- 粉色系眼影
|
|
|
+- 单簇假睫毛
|
|
|
+
|
|
|
+# 任务
|
|
|
+1. 保留需求核心词:内双、肿眼泡、眼妆。
|
|
|
+2. 把风格词压缩为“清透眼妆”“纯欲眼妆”等短词。
|
|
|
+3. 把物品词绑定到眼妆教程,不输出孤立物品词。
|
|
|
+4. 如果 query 过长,回退为“内双/肿眼泡 + 眼妆教程”。
|
|
|
+5. 输出适合直接交给抖音关键词搜索的 query。
|
|
|
+
|
|
|
+# 约束
|
|
|
+1. 不加空格。
|
|
|
+2. 不加复杂标点。
|
|
|
+3. 不输出品牌、单品种草词作为主 query。
|
|
|
+4. 优先让 query 像用户会在抖音搜索框输入的词。
|
|
|
+
|
|
|
+# 输出格式
|
|
|
+最终 query:
|
|
|
+- ...
|
|
|
+
|
|
|
+淘汰 query:
|
|
|
+- ...,原因:...`,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ queryGroups: [
|
|
|
+ {
|
|
|
+ evidenceTitle: "内双肿眼泡",
|
|
|
+ queries: ["内双肿眼泡眼妆教程", "内双肿眼泡眼妆", "肿眼泡眼妆教程"],
|
|
|
+ note: "主种子,最适合作为第一批召回 query。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ evidenceTitle: "清透纯欲眼妆",
|
|
|
+ queries: ["清透纯欲眼妆教程", "内双清透眼妆", "肿眼泡清透眼妆"],
|
|
|
+ note: "风格种子,需要绑定内双/肿眼泡防止变成泛妆容。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ evidenceTitle: "粉色系眼影",
|
|
|
+ queries: ["肿眼泡粉色眼影", "内双粉色眼妆", "粉色眼影眼妆教程"],
|
|
|
+ note: "物品种子,只做扩展召回,不能单独作为主 query。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ evidenceTitle: "单簇假睫毛",
|
|
|
+ queries: ["肿眼泡假睫毛教程", "单簇假睫毛眼妆", "内双假睫毛教程"],
|
|
|
+ note: "物品种子,适合补充睫毛教程和眼部细节内容。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ evidenceTitle: "组合候选",
|
|
|
+ queries: ["内双清透眼妆教程", "肿眼泡清透眼妆教程", "内双粉色眼妆教程"],
|
|
|
+ note: "由多个下层特征组合而来,进入平台压缩后再执行。",
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ selectedQuery: "内双肿眼泡眼妆教程",
|
|
|
+};
|
|
|
+
|
|
|
+const patternQueryWorkshop: QueryWorkshop = {
|
|
|
+ evidence: [
|
|
|
+ { title: "眼妆 + 眼影 + 眼线 + 睫毛", kind: "实质组合", source: "Pattern item" },
|
|
|
+ { title: "图示说明 + 步骤拆解 + 课堂讲座", kind: "形式组合", source: "Pattern item" },
|
|
|
+ { title: "物品展示 + 通用技法 + 时序变化对比", kind: "呈现组合", source: "Pattern item" },
|
|
|
+ { title: "彩妆 + 妆容风格 + 构成单元", kind: "上位组合", source: "Pattern item" },
|
|
|
+ ],
|
|
|
+ prompts: [
|
|
|
+ {
|
|
|
+ title: "Pattern 组合 Prompt",
|
|
|
+ content: "把 Pattern item 里的实质、形式和呈现方式组合成平台 query。",
|
|
|
+ fullContent: `# 角色
|
|
|
+你是 Content Find Agent 的 Pattern Query Builder,负责把 Pattern item 组合成平台可搜索 query。
|
|
|
+
|
|
|
+# 输入
|
|
|
+Pattern item:
|
|
|
+- 实质组合:眼妆、眼影、眼线、睫毛
|
|
|
+- 形式组合:图示说明、步骤拆解、课堂讲座
|
|
|
+- 呈现组合:物品展示、通用技法、时序变化对比
|
|
|
+- 上位组合:彩妆、妆容风格、构成单元
|
|
|
+
|
|
|
+# 任务
|
|
|
+1. 先识别 Pattern 的核心主题,优先保留“眼妆”。
|
|
|
+2. 从实质组合里抽主 query 种子。
|
|
|
+3. 从形式组合里抽修饰词,例如教程、步骤、图解。
|
|
|
+4. 从呈现组合里抽扩展方式,例如前后对比、物品展示。
|
|
|
+5. 生成主 query、形式扩展 query、细节扩展 query。
|
|
|
+
|
|
|
+# 约束
|
|
|
+1. 不引用任何真实 Case 标题或账号。
|
|
|
+2. 每个 query 至少回扣两个 Pattern item。
|
|
|
+3. 不生成过宽泛词,例如“彩妆教程”只能作为低优先级扩展。
|
|
|
+4. 不把形式词单独作为 query,例如只输出“步骤拆解”不合格。
|
|
|
+5. query 要短,适合抖音搜索。
|
|
|
+
|
|
|
+# 输出格式
|
|
|
+[
|
|
|
+ {
|
|
|
+ "query": "...",
|
|
|
+ "类型": "主 query / 形式扩展 / 细节扩展",
|
|
|
+ "回扣 Pattern": ["...", "..."]
|
|
|
+ }
|
|
|
+]`,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "平台可搜 Prompt",
|
|
|
+ content: "把 Pattern 候选 query 压缩成抖音可搜索、可召回的短词。",
|
|
|
+ fullContent: `# 角色
|
|
|
+你是抖音搜索改写模块,负责把 Pattern 候选 query 改写成抖音搜索可用表达。
|
|
|
+
|
|
|
+# 输入
|
|
|
+候选 query 来自 Pattern item 组合,可能包含抽象分类词或形式词。
|
|
|
+
|
|
|
+# 任务
|
|
|
+1. 保留平台可搜的主题词,例如眼妆、眼影、眼线、睫毛。
|
|
|
+2. 把抽象形式词改写成用户会搜索的词,例如教程、步骤、图解。
|
|
|
+3. 先生成一个主题主词,再生成若干细节扩展词。
|
|
|
+4. 每个 query 都要能回扣至少两个 Pattern item。
|
|
|
+5. 把过抽象、过长、不可搜索的候选丢弃。
|
|
|
+
|
|
|
+# 过滤规则
|
|
|
+1. “构成单元、通用技法、课堂讲座”不能直接输出为 query。
|
|
|
+2. 如果 query 只剩上位词,例如“彩妆”,要降级或淘汰。
|
|
|
+3. 如果 query 像分类名而不是搜索词,要改写成用户口吻。
|
|
|
+4. 不输出解释性长句。
|
|
|
+
|
|
|
+# 输出格式
|
|
|
+最终 query:
|
|
|
+- ...
|
|
|
+
|
|
|
+可选扩展:
|
|
|
+- ...
|
|
|
+
|
|
|
+淘汰:
|
|
|
+- ...,原因:...`,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ queryGroups: [
|
|
|
+ {
|
|
|
+ evidenceTitle: "实质组合",
|
|
|
+ queries: ["眼妆教程", "眼影眼线教程", "睫毛眼妆教程"],
|
|
|
+ note: "主 query,已实测“眼妆教程”返回 10 条。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ evidenceTitle: "形式组合",
|
|
|
+ queries: ["眼妆步骤教程", "眼妆图解教程", "新手眼妆教程"],
|
|
|
+ note: "强调步骤和教学,适合下一轮小样本测试。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ evidenceTitle: "呈现组合",
|
|
|
+ queries: ["眼妆前后对比", "眼妆局部特写", "眼妆物品展示"],
|
|
|
+ note: "偏二跳和筛选,不一定适合作为首发 query。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ evidenceTitle: "上位组合",
|
|
|
+ queries: ["彩妆教程", "妆容教程", "新手彩妆教程"],
|
|
|
+ note: "上位词覆盖更广,只在主 query 结果过少时补充。",
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ selectedQuery: "眼妆教程",
|
|
|
+};
|
|
|
+
|
|
|
+function findPostSource(sourceId: PostSourceId) {
|
|
|
+ return postSourceOptions.find((source) => source.id === sourceId) ?? postSourceOptions[0];
|
|
|
+}
|
|
|
+
|
|
|
+function getDataSourcePanelConfig(laneId: SourceLaneId) {
|
|
|
+ return dataSourcePanels[laneId] ?? dataSourcePanels.pattern;
|
|
|
+}
|
|
|
+
|
|
|
+function getInitialRecordIdByLane(): Record<SourceLaneId, string> {
|
|
|
+ return sourceLanes.reduce((recordIds, lane) => ({
|
|
|
+ ...recordIds,
|
|
|
+ [lane.id]: getDataSourcePanelConfig(lane.id).records[0]?.id ?? "",
|
|
|
+ }), {} as Record<SourceLaneId, string>);
|
|
|
+}
|
|
|
+
|
|
|
+function getInitialValueByLane(value = ""): Record<SourceLaneId, string> {
|
|
|
+ return sourceLanes.reduce((values, lane) => ({
|
|
|
+ ...values,
|
|
|
+ [lane.id]: value,
|
|
|
+ }), {} as Record<SourceLaneId, string>);
|
|
|
+}
|
|
|
+
|
|
|
+function getFilteredDataSourceRecords(config: DataSourcePanelConfig, filter: string, search: string) {
|
|
|
+ const normalizedSearch = search.trim().toLowerCase();
|
|
|
+
|
|
|
+ return config.records.filter((record) => {
|
|
|
+ if (filter !== "all" && record.filterValue !== filter) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!normalizedSearch) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ const searchableText = [
|
|
|
+ record.id,
|
|
|
+ record.title,
|
|
|
+ record.subtitle,
|
|
|
+ record.badge,
|
|
|
+ record.evidenceStatus,
|
|
|
+ record.filterValue,
|
|
|
+ ...record.tags,
|
|
|
+ ...record.metrics.map((metric) => `${metric.label} ${metric.value}`),
|
|
|
+ ...record.materialRows.map((row) => `${row.label} ${row.value}`),
|
|
|
+ ...record.seedRows.map((row) => `${row.label} ${row.value}`),
|
|
|
+ ...record.searchableText,
|
|
|
+ ].join(" ").toLowerCase();
|
|
|
+
|
|
|
+ return searchableText.includes(normalizedSearch);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function buildCaseDataSourceContent(postSourceId: PostSourceId): StageContent {
|
|
|
+ const postSource = findPostSource(postSourceId);
|
|
|
+
|
|
|
+ if (postSource.id !== "xhs") {
|
|
|
+ return {
|
|
|
+ title: "Case 数据源",
|
|
|
+ status: "placeholder",
|
|
|
+ badge: "待补真实案例",
|
|
|
+ eyebrow: "",
|
|
|
+ sections: [
|
|
|
+ {
|
|
|
+ title: "帖子详情",
|
|
|
+ postSourceControl: true,
|
|
|
+ rows: [
|
|
|
+ { label: "帖子来源", value: postSource.label },
|
|
|
+ { label: "状态", value: "该帖子来源待补真实案例" },
|
|
|
+ ],
|
|
|
+ body: "当前只跑通了小红书来源的真实 Case。其他帖子来源先保留入口,不伪造内容。",
|
|
|
+ tone: "warning",
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ title: "Case 数据源",
|
|
|
+ status: "verified",
|
|
|
+ eyebrow: "",
|
|
|
+ sections: [
|
|
|
+ {
|
|
|
+ title: "帖子详情",
|
|
|
+ postSourceControl: true,
|
|
|
+ rows: [
|
|
|
+ { label: "帖子来源", value: postSource.label },
|
|
|
+ { label: "case_id", value: "6798cfa1000000001801ab86" },
|
|
|
+ { label: "标题", value: "内双肿眼泡 快去试这个清透纯欲感眼妆~" },
|
|
|
+ { label: "账号", value: "单眼皮倩倩" },
|
|
|
+ { label: "互动", value: "like=96, collect=64, comment=0" },
|
|
|
+ ],
|
|
|
+ images: caseImages,
|
|
|
+ body: "Decode 摘要:通过分步骤图解和标注式操作指引,教授内双肿眼泡如何打造清透纯欲感眼妆,用前后对比呈现妆效,展示粉色系眼影和单簇假睫毛的具体使用手法。",
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function buildDouyinPlatformContent(): StageContent {
|
|
|
+ return {
|
|
|
+ title: "抖音平台接入",
|
|
|
+ status: "verified",
|
|
|
+ badge: "产品策略",
|
|
|
+ sections: [
|
|
|
+ {
|
|
|
+ title: "平台能力总览",
|
|
|
+ table: {
|
|
|
+ columns: ["能力", "抖音口径", "策略用途"],
|
|
|
+ rows: [
|
|
|
+ ["可搜索对象", "视频、作者、热点", "承接 Query,形成第一批候选"],
|
|
|
+ ["可扩展对象", "作者作品、相关搜索、标签/话题", "给游走提供下一跳"],
|
|
|
+ ["可评价信号", "标题、作者、互动、发布时间、画像", "交给判断规则包使用"],
|
|
|
+ ["风险边界", "跑偏、种草、低质、不可访问、重复", "决定降预算或停止"],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "Agent 可执行行为",
|
|
|
+ table: {
|
|
|
+ columns: ["行为", "输入", "产出", "去向"],
|
|
|
+ rows: [
|
|
|
+ ["关键词搜视频", "Query", "视频候选", "判断"],
|
|
|
+ ["拉作者作品", "作者", "作者作品", "判断 / 游走"],
|
|
|
+ ["提取相关搜索", "视频或搜索结果", "二跳搜索词", "Query"],
|
|
|
+ ["提取标签/话题", "视频文本", "标签线索", "Query"],
|
|
|
+ ["读取热点榜", "热点入口", "修饰词", "Query"],
|
|
|
+ ["采集推荐流", "养号账号", "推荐 case", "数据源 / 判断"],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "平台接入规则",
|
|
|
+ chips: [
|
|
|
+ "Query 压缩成短词",
|
|
|
+ "优先搜索视频",
|
|
|
+ "过长 Query 回退",
|
|
|
+ "作者作品单独拉取",
|
|
|
+ "相关搜索只做二跳线索",
|
|
|
+ "热点只做修饰",
|
|
|
+ "推荐流先当 case",
|
|
|
+ "不可访问直接停止",
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+const demoScenarios: DemoScenario[] = [
|
|
|
+ {
|
|
|
+ id: "pattern-douyin",
|
|
|
+ laneId: "pattern",
|
|
|
+ platformId: "douyin",
|
|
|
+ label: "Pattern + 抖音",
|
|
|
+ stages: {
|
|
|
+ dataSource: {
|
|
|
+ title: "Pattern #1151441",
|
|
|
+ status: "verified",
|
|
|
+ note: "Pattern 起点只使用 item 组合,不使用 matched case 作为起点。",
|
|
|
+ sections: [
|
|
|
+ {
|
|
|
+ title: "原始证据",
|
|
|
+ rows: [
|
|
|
+ { label: "Pattern ID", value: "#1151441" },
|
|
|
+ { label: "执行快照", value: "execution_id=401, mining_config_id=1301" },
|
|
|
+ { label: "支持帖子", value: "absolute_support=11, matched_post_total=11" },
|
|
|
+ { label: "项数", value: "item_count=16" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "Pattern item",
|
|
|
+ chips: patternItems,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "边界",
|
|
|
+ body: "本案例展示从 Pattern 直接组合联想,不读取真实 case 的标题、图片或账号信息。",
|
|
|
+ tone: "notice",
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ query: {
|
|
|
+ title: "眼妆教程",
|
|
|
+ status: "verified",
|
|
|
+ sections: [
|
|
|
+ {
|
|
|
+ title: "Query 工作台",
|
|
|
+ queryWorkshop: patternQueryWorkshop,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ platform: buildDouyinPlatformContent(),
|
|
|
+ judge: {
|
|
|
+ title: "Pattern 回扣评分",
|
|
|
+ status: "verified",
|
|
|
+ sections: [],
|
|
|
+ judgmentRuleGroups: buildJudgmentRuleGroups("pattern"),
|
|
|
+ },
|
|
|
+ walk: {
|
|
|
+ title: "相关搜索 + 作者作品",
|
|
|
+ status: "verified",
|
|
|
+ sections: [
|
|
|
+ {
|
|
|
+ title: "游走结果",
|
|
|
+ table: {
|
|
|
+ columns: ["走法", "起点", "结果", "继续条件", "停止条件"],
|
|
|
+ rows: [
|
|
|
+ ["相关搜索", "Top 候选", "下睫毛画法、蓝瞳妆、欧美妆美瞳", "新词仍能回扣眼妆/教程", "变成泛美瞳/泛遮瑕时停"],
|
|
|
+ ["作者作品", "头头. sec_uid", "作者作品接口 code=0,返回 22 条", "近期作品仍是妆教/眼妆", "作者作品偏穿搭或泛颜值时停"],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "作者作品只读摘要",
|
|
|
+ rows: [
|
|
|
+ { label: "接口", value: "POST /crawler/dou_yin/blogger" },
|
|
|
+ { label: "返回", value: "HTTP 200, code=0, count=22" },
|
|
|
+ { label: "首条", value: "新宝宝 #winkwink #弥生水星记" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ asset: {
|
|
|
+ title: "模拟资产 4 类",
|
|
|
+ status: "verified",
|
|
|
+ badge: "模拟展示",
|
|
|
+ note: "只在页面展示,不写数据库。",
|
|
|
+ sections: [
|
|
|
+ {
|
|
|
+ title: "虚拟沉淀资产",
|
|
|
+ table: {
|
|
|
+ columns: ["资产", "示例", "状态"],
|
|
|
+ rows: [
|
|
|
+ ["候选 Case", "去重后眼妆教程候选 9 条", "页面模拟,不入库"],
|
|
|
+ ["有效 Query", "眼妆教程", "页面模拟,不入库"],
|
|
|
+ ["作者线索", "头头. / sec_uid", "页面模拟,不入库"],
|
|
|
+ ["淘汰原因", "过长 query 参数校验失败、泛美瞳相关词降权", "页面模拟,不入库"],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "清洗动作",
|
|
|
+ chips: ["aweme_id 去重", "作者重复合并", "query 成功/失败标记", "Pattern 回扣标记"],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ learning: {
|
|
|
+ title: "学习建议",
|
|
|
+ status: "verified",
|
|
|
+ badge: "模拟展示",
|
|
|
+ note: "这里是策略学习的展示,不执行真实学习或写入。",
|
|
|
+ sections: [
|
|
|
+ {
|
|
|
+ title: "策略学习模拟",
|
|
|
+ table: {
|
|
|
+ columns: ["观察", "建议", "下轮动作"],
|
|
|
+ rows: [
|
|
|
+ ["短 query 成功,长组合 query 失败", "Pattern query 先短后细分", "优先搜眼妆教程,再拆眼线/睫毛"],
|
|
|
+ ["相关搜索出现美瞳/遮瑕漂移", "二跳要做 Pattern 回扣", "不命中眼妆教程则停"],
|
|
|
+ ["作者作品返回较多", "作者二跳可保留小预算", "每个作者先取 10-20 条再评估"],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "case-douyin",
|
|
|
+ laneId: "case",
|
|
|
+ platformId: "douyin",
|
|
|
+ label: "Case + 抖音",
|
|
|
+ stages: {
|
|
|
+ dataSource: buildCaseDataSourceContent("xhs"),
|
|
|
+ query: {
|
|
|
+ title: "内双肿眼泡眼妆教程",
|
|
|
+ status: "verified",
|
|
|
+ sections: [
|
|
|
+ {
|
|
|
+ title: "Query 工作台",
|
|
|
+ queryWorkshop: caseQueryWorkshop,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ platform: buildDouyinPlatformContent(),
|
|
|
+ judge: {
|
|
|
+ title: "Case 对齐评分",
|
|
|
+ status: "verified",
|
|
|
+ sections: [],
|
|
|
+ judgmentRuleGroups: buildJudgmentRuleGroups("case"),
|
|
|
+ },
|
|
|
+ walk: {
|
|
|
+ title: "作者作品 + 相关搜索",
|
|
|
+ status: "verified",
|
|
|
+ sections: [
|
|
|
+ {
|
|
|
+ title: "游走结果",
|
|
|
+ table: {
|
|
|
+ columns: ["走法", "起点", "结果", "继续条件", "停止条件"],
|
|
|
+ rows: [
|
|
|
+ ["作者作品", "毛头小资 sec_uid", "作者作品接口 code=0,返回 21 条", "仍围绕肿眼泡/内双/妆教", "转向泛测评或泛好物时停"],
|
|
|
+ ["相关搜索", "Top 候选", "硬梗假睫毛、卧蚕、下至卧蚕笔", "能解释回原 case 的关键点", "只剩产品种草或泛妆容时停"],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "作者作品只读摘要",
|
|
|
+ rows: [
|
|
|
+ { label: "接口", value: "POST /crawler/dou_yin/blogger" },
|
|
|
+ { label: "返回", value: "HTTP 200, code=0, count=21" },
|
|
|
+ { label: "首条", value: "热门风大双眼皮贴测评来啦!肿眼泡想要贴好双眼皮选对产品很重要" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ asset: {
|
|
|
+ title: "模拟资产 5 类",
|
|
|
+ status: "verified",
|
|
|
+ badge: "模拟展示",
|
|
|
+ note: "只在页面展示,不写数据库。",
|
|
|
+ sections: [
|
|
|
+ {
|
|
|
+ title: "虚拟沉淀资产",
|
|
|
+ table: {
|
|
|
+ columns: ["资产", "示例", "状态"],
|
|
|
+ rows: [
|
|
|
+ ["候选 Case", "内双肿眼泡眼妆教程候选 10 条,去重后保留 8-9 条", "页面模拟,不入库"],
|
|
|
+ ["有效 Query", "内双肿眼泡眼妆教程 / 清透纯欲眼妆教程", "页面模拟,不入库"],
|
|
|
+ ["作者线索", "毛头小资 / sec_uid", "页面模拟,不入库"],
|
|
|
+ ["二跳词", "肿眼泡假睫毛推荐、卧蚕怎么画", "页面模拟,不入库"],
|
|
|
+ ["淘汰原因", "只剩产品种草、未命中内双肿眼泡、重复度高", "页面模拟,不入库"],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "清洗动作",
|
|
|
+ chips: ["跨平台 case 标记", "候选去重", "作者合并", "失败 query 记录", "二跳来源归因"],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ learning: {
|
|
|
+ title: "学习建议",
|
|
|
+ status: "verified",
|
|
|
+ badge: "模拟展示",
|
|
|
+ note: "这里是策略学习展示,不执行真实学习或写入。",
|
|
|
+ sections: [
|
|
|
+ {
|
|
|
+ title: "策略学习模拟",
|
|
|
+ table: {
|
|
|
+ columns: ["观察", "建议", "下轮动作"],
|
|
|
+ rows: [
|
|
|
+ ["痛点 query 命中更准", "Case query 要保留痛点词", "优先保留内双/肿眼泡"],
|
|
|
+ ["风格词可扩展但容易变泛", "清透纯欲作为补充,不做主 query", "风格词只用于二跳或改写"],
|
|
|
+ ["作者作品高度相关", "作者二跳适合本 case", "给作者作品 10-20 条小预算"],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+const initialSelection: Selection = {
|
|
|
+ laneId: "pattern",
|
|
|
+ platformId: "douyin",
|
|
|
+ stageId: "dataSource",
|
|
|
+ postSourceId: "xhs",
|
|
|
+ dataSourceRecordIdByLane: getInitialRecordIdByLane(),
|
|
|
+ dataSourceFilterByLane: { ...getInitialValueByLane("all"), case: "piaoquan" },
|
|
|
+ dataSourceSearchByLane: getInitialValueByLane(),
|
|
|
+};
|
|
|
+
|
|
|
+function App() {
|
|
|
+ const [selected, setSelected] = useState<Selection>(initialSelection);
|
|
|
+ const view = useMemo(() => buildView(selected), [selected]);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="app-shell">
|
|
|
+ <main className="workspace">
|
|
|
+ <PipelineHeader selected={selected} onSelect={setSelected} />
|
|
|
+ {selected.stageId === "dataSource" ? (
|
|
|
+ <DataSourceStageBrowser selected={selected} onSelect={setSelected} />
|
|
|
+ ) : (
|
|
|
+ <NodeDetailPanel selected={selected} onSelect={setSelected} view={view} />
|
|
|
+ )}
|
|
|
+ </main>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function buildView(selected: Selection) {
|
|
|
+ const lane = sourceLanes.find((item) => item.id === selected.laneId) ?? sourceLanes[0];
|
|
|
+ const platform = platformOptions.find((item) => item.id === selected.platformId) ?? platformOptions[0];
|
|
|
+ const stage = pipelineStages.find((item) => item.id === selected.stageId) ?? pipelineStages[0];
|
|
|
+ const scenario = demoScenarios.find((item) => item.laneId === lane.id && item.platformId === platform.id);
|
|
|
+ const baseStageContent = scenario?.stages[stage.id] ?? placeholderStageContent(lane, platform, stage);
|
|
|
+ const stageContent = withTraceItems(
|
|
|
+ scenario?.id === "case-douyin" && stage.id === "dataSource"
|
|
|
+ ? buildCaseDataSourceContent(selected.postSourceId)
|
|
|
+ : baseStageContent,
|
|
|
+ lane,
|
|
|
+ platform,
|
|
|
+ stage,
|
|
|
+ );
|
|
|
+
|
|
|
+ return { lane, platform, stage, scenario, stageContent };
|
|
|
+}
|
|
|
+
|
|
|
+function withTraceItems(
|
|
|
+ stageContent: StageContent,
|
|
|
+ lane: SourceLane,
|
|
|
+ platform: PlatformOption,
|
|
|
+ stage: PipelineStage,
|
|
|
+): StageContent {
|
|
|
+ return {
|
|
|
+ ...stageContent,
|
|
|
+ traceItems: stageContent.traceItems ?? defaultTraceItems(lane, platform, stage),
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function defaultTraceItems(lane: SourceLane, platform: PlatformOption, stage: PipelineStage) {
|
|
|
+ const sourceSubject = lane.id === "case" ? "Case ID、帖子来源、标题、账号、图片、Decode 摘要" : "Pattern ID、item 组合、支持数、证据范围";
|
|
|
+ const items: Record<PipelineStageId, string[]> = {
|
|
|
+ dataSource: [`追溯数据源类型:${lane.name}`, `追溯原始证据:${sourceSubject}`],
|
|
|
+ query: ["追溯下层特征", "追溯 Query 输入素材", "追溯 Prompt 版本", "追溯 Query 组合", "追溯淘汰原因"],
|
|
|
+ platform: [`追溯执行平台:${platform.label}`, "追溯接口类型、Query、payload 口径、候选视频 ID、作者 ID、相关搜索词"],
|
|
|
+ judge: ["追溯候选 ID、评分维度、保留/淘汰结论、回扣到哪个 Pattern 或 Case 证据"],
|
|
|
+ walk: ["追溯游走起点、二跳对象、继续条件、停止条件、游走预算"],
|
|
|
+ asset: ["追溯虚拟资产类型、去重 key、保留原因、淘汰原因,明确不入库"],
|
|
|
+ learning: ["追溯成功/失败观察、建议调整项、下轮动作,明确不真实写入学习结果"],
|
|
|
+ };
|
|
|
+
|
|
|
+ return items[stage.id];
|
|
|
+}
|
|
|
+
|
|
|
+function placeholderStageContent(lane: SourceLane, platform: PlatformOption, stage: PipelineStage): StageContent {
|
|
|
+ const stageTitle: Record<PipelineStageId, string> = {
|
|
|
+ dataSource: lane.name,
|
|
|
+ query: "待补 Query",
|
|
|
+ platform: platform.label,
|
|
|
+ judge: "待补判断",
|
|
|
+ walk: "待补游走",
|
|
|
+ asset: "待补资产",
|
|
|
+ learning: "待补学习",
|
|
|
+ };
|
|
|
+
|
|
|
+ return {
|
|
|
+ title: stageTitle[stage.id],
|
|
|
+ status: "placeholder",
|
|
|
+ note: "这个数据源和平台组合还没有跑真实案例。",
|
|
|
+ traceItems: defaultTraceItems(lane, platform, stage),
|
|
|
+ sections: [
|
|
|
+ {
|
|
|
+ title: "占位",
|
|
|
+ rows: [
|
|
|
+ { label: "数据源", value: lane.name },
|
|
|
+ { label: "Platform", value: platform.label },
|
|
|
+ { label: "当前阶段", value: stage.label },
|
|
|
+ { label: "状态", value: "待补真实案例" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "后续需要补什么",
|
|
|
+ chips: ["原始证据", "候选 Query", "平台返回", "判断评分", "游走结果", "模拟资产", "学习建议"],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function PipelineHeader({
|
|
|
+ selected,
|
|
|
+ onSelect,
|
|
|
+}: {
|
|
|
+ selected: Selection;
|
|
|
+ onSelect: (next: Selection) => void;
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <section className="pipeline-header" aria-label="完整链路">
|
|
|
+ <div className="pipeline-grid">
|
|
|
+ {pipelineStages.map((stage) => {
|
|
|
+ if (stage.id === "dataSource") {
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ className={selected.stageId === stage.id ? "pipeline-step selectable active" : "pipeline-step selectable"}
|
|
|
+ key={stage.id}
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ className="pipeline-stage-button"
|
|
|
+ onClick={() => onSelect({ ...selected, stageId: "dataSource" })}
|
|
|
+ type="button"
|
|
|
+ >
|
|
|
+ {stage.label}
|
|
|
+ </button>
|
|
|
+ <select
|
|
|
+ value={selected.laneId}
|
|
|
+ onChange={(event) => {
|
|
|
+ const nextLaneId = event.target.value as SourceLaneId;
|
|
|
+ const nextConfig = getDataSourcePanelConfig(nextLaneId);
|
|
|
+ onSelect({
|
|
|
+ ...selected,
|
|
|
+ laneId: nextLaneId,
|
|
|
+ stageId: "dataSource",
|
|
|
+ dataSourceRecordIdByLane: {
|
|
|
+ ...selected.dataSourceRecordIdByLane,
|
|
|
+ [nextLaneId]: selected.dataSourceRecordIdByLane[nextLaneId] || nextConfig.records[0]?.id || "",
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {sourceLanes.map((lane) => (
|
|
|
+ <option value={lane.id} key={lane.id}>{lane.name}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (stage.id === "platform") {
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ className={selected.stageId === stage.id ? "pipeline-step selectable active" : "pipeline-step selectable"}
|
|
|
+ key={stage.id}
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ className="pipeline-stage-button"
|
|
|
+ onClick={() => onSelect({ ...selected, stageId: "platform" })}
|
|
|
+ type="button"
|
|
|
+ >
|
|
|
+ {stage.label}
|
|
|
+ </button>
|
|
|
+ <select
|
|
|
+ value={selected.platformId}
|
|
|
+ onChange={(event) => onSelect({
|
|
|
+ ...selected,
|
|
|
+ platformId: event.target.value,
|
|
|
+ stageId: "platform",
|
|
|
+ })}
|
|
|
+ >
|
|
|
+ {platformOptions.map((platform) => (
|
|
|
+ <option value={platform.id} key={platform.id}>{platform.label}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <button
|
|
|
+ className={selected.stageId === stage.id ? "pipeline-step active" : "pipeline-step"}
|
|
|
+ onClick={() => onSelect({ ...selected, stageId: stage.id })}
|
|
|
+ key={stage.id}
|
|
|
+ >
|
|
|
+ {stage.label}
|
|
|
+ </button>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function DataSourceStageBrowser({
|
|
|
+ selected,
|
|
|
+ onSelect,
|
|
|
+}: {
|
|
|
+ selected: Selection;
|
|
|
+ onSelect: (next: Selection) => void;
|
|
|
+}) {
|
|
|
+ const [isListCollapsed, setIsListCollapsed] = useState(false);
|
|
|
+ const config = getDataSourcePanelConfig(selected.laneId);
|
|
|
+ const rawFilter = selected.dataSourceFilterByLane[selected.laneId] ?? "all";
|
|
|
+ const filter = config.filterOptions.some((option) => option.id === rawFilter) ? rawFilter : config.filterOptions[0]?.id ?? "all";
|
|
|
+ const search = selected.dataSourceSearchByLane[selected.laneId] ?? "";
|
|
|
+ const filteredRecords = useMemo(
|
|
|
+ () => getFilteredDataSourceRecords(config, filter, search),
|
|
|
+ [config, filter, search],
|
|
|
+ );
|
|
|
+ const selectedRecordId = selected.dataSourceRecordIdByLane[selected.laneId] ?? "";
|
|
|
+ const activeRecord = filteredRecords.find((record) => record.id === selectedRecordId) ?? filteredRecords[0];
|
|
|
+
|
|
|
+ function firstVisibleRecordId(nextFilter: string, nextSearch: string) {
|
|
|
+ const nextRecords = getFilteredDataSourceRecords(config, nextFilter, nextSearch);
|
|
|
+ return nextRecords.find((record) => record.id === selectedRecordId)?.id ?? nextRecords[0]?.id ?? "";
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleFilterChange(nextFilter: string) {
|
|
|
+ onSelect({
|
|
|
+ ...selected,
|
|
|
+ dataSourceFilterByLane: {
|
|
|
+ ...selected.dataSourceFilterByLane,
|
|
|
+ [selected.laneId]: nextFilter,
|
|
|
+ },
|
|
|
+ dataSourceRecordIdByLane: {
|
|
|
+ ...selected.dataSourceRecordIdByLane,
|
|
|
+ [selected.laneId]: firstVisibleRecordId(nextFilter, search),
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleSearchChange(nextSearch: string) {
|
|
|
+ onSelect({
|
|
|
+ ...selected,
|
|
|
+ dataSourceSearchByLane: {
|
|
|
+ ...selected.dataSourceSearchByLane,
|
|
|
+ [selected.laneId]: nextSearch,
|
|
|
+ },
|
|
|
+ dataSourceRecordIdByLane: {
|
|
|
+ ...selected.dataSourceRecordIdByLane,
|
|
|
+ [selected.laneId]: firstVisibleRecordId(filter, nextSearch),
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <SourceFilterBar
|
|
|
+ config={config}
|
|
|
+ filter={filter}
|
|
|
+ search={search}
|
|
|
+ onFilterChange={handleFilterChange}
|
|
|
+ onSearchChange={handleSearchChange}
|
|
|
+ />
|
|
|
+ <section className={isListCollapsed ? "source-browser collapsed" : "source-browser"}>
|
|
|
+ <SourceRecordList
|
|
|
+ activeRecordId={activeRecord?.id ?? ""}
|
|
|
+ config={config}
|
|
|
+ isCollapsed={isListCollapsed}
|
|
|
+ records={filteredRecords}
|
|
|
+ totalCount={filteredRecords.length}
|
|
|
+ onCollapseChange={setIsListCollapsed}
|
|
|
+ onSelectRecord={(recordId) => onSelect({
|
|
|
+ ...selected,
|
|
|
+ dataSourceRecordIdByLane: {
|
|
|
+ ...selected.dataSourceRecordIdByLane,
|
|
|
+ [selected.laneId]: recordId,
|
|
|
+ },
|
|
|
+ })}
|
|
|
+ />
|
|
|
+ <SourceRecordDetail
|
|
|
+ config={config}
|
|
|
+ record={activeRecord}
|
|
|
+ />
|
|
|
+ </section>
|
|
|
+ <LearningTraceStrip items={getLearningTraceItems("dataSource")} />
|
|
|
+ </>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function SourceFilterBar({
|
|
|
+ config,
|
|
|
+ filter,
|
|
|
+ search,
|
|
|
+ onFilterChange,
|
|
|
+ onSearchChange,
|
|
|
+}: {
|
|
|
+ config: DataSourcePanelConfig;
|
|
|
+ filter: string;
|
|
|
+ search: string;
|
|
|
+ onFilterChange: (next: string) => void;
|
|
|
+ onSearchChange: (next: string) => void;
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <section className="source-filter-bar" aria-label="数据源筛选">
|
|
|
+ <div className="source-filter-title">
|
|
|
+ <span>当前数据源</span>
|
|
|
+ <strong>{config.title}</strong>
|
|
|
+ </div>
|
|
|
+ {config.filterOptions.some((option) => option.id !== "all") ? (
|
|
|
+ <div className="source-filter-row">
|
|
|
+ <span className="filter-label">{config.filterLabel}</span>
|
|
|
+ <div className="source-segmented" role="group" aria-label={config.filterLabel}>
|
|
|
+ {config.filterOptions.map((option) => (
|
|
|
+ <button
|
|
|
+ className={filter === option.id ? "active" : ""}
|
|
|
+ key={`${config.laneId}-${option.id}`}
|
|
|
+ onClick={() => onFilterChange(option.id)}
|
|
|
+ type="button"
|
|
|
+ >
|
|
|
+ {option.label}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ <label className="source-search-control">
|
|
|
+ <span>{config.searchLabel}</span>
|
|
|
+ <input
|
|
|
+ value={search}
|
|
|
+ onChange={(event) => onSearchChange(event.target.value)}
|
|
|
+ placeholder={config.searchPlaceholder}
|
|
|
+ />
|
|
|
+ </label>
|
|
|
+ </section>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function SourceRecordList({
|
|
|
+ records,
|
|
|
+ activeRecordId,
|
|
|
+ totalCount,
|
|
|
+ config,
|
|
|
+ isCollapsed,
|
|
|
+ onCollapseChange,
|
|
|
+ onSelectRecord,
|
|
|
+}: {
|
|
|
+ records: DataSourceRecord[];
|
|
|
+ activeRecordId: string;
|
|
|
+ totalCount: number;
|
|
|
+ config: DataSourcePanelConfig;
|
|
|
+ isCollapsed: boolean;
|
|
|
+ onCollapseChange: (next: boolean) => void;
|
|
|
+ onSelectRecord: (recordId: string) => void;
|
|
|
+}) {
|
|
|
+ if (isCollapsed) {
|
|
|
+ return (
|
|
|
+ <aside className="source-list-panel collapsed" aria-label={`${config.title}列表`}>
|
|
|
+ <button className="list-collapse-button vertical" onClick={() => onCollapseChange(false)} type="button">
|
|
|
+ 展开
|
|
|
+ </button>
|
|
|
+ </aside>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <aside className="source-list-panel" aria-label={`${config.title}列表`}>
|
|
|
+ <div className="source-list-head">
|
|
|
+ <span>共 {totalCount} 条</span>
|
|
|
+ <button className="list-collapse-button" onClick={() => onCollapseChange(true)} type="button">
|
|
|
+ 收起
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div className="source-list-scroll">
|
|
|
+ {records.length ? (
|
|
|
+ records.map((record) => {
|
|
|
+ const patternListCard = config.laneId === "pattern" ? record.patternListCard : undefined;
|
|
|
+ const isPatternCard = Boolean(patternListCard);
|
|
|
+ const cardClassName = [
|
|
|
+ "source-record-card",
|
|
|
+ isPatternCard ? "pattern-card" : "",
|
|
|
+ record.id === activeRecordId ? "active" : "",
|
|
|
+ ].filter(Boolean).join(" ");
|
|
|
+
|
|
|
+ return (
|
|
|
+ <button
|
|
|
+ className={cardClassName}
|
|
|
+ key={record.id}
|
|
|
+ onClick={() => onSelectRecord(record.id)}
|
|
|
+ type="button"
|
|
|
+ >
|
|
|
+ {patternListCard ? (
|
|
|
+ <PatternCardView card={patternListCard} recordId={record.id} />
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ <span className={record.status === "verified" ? "source-record-icon verified" : "source-record-icon"}>
|
|
|
+ {record.badge.slice(0, 2)}
|
|
|
+ </span>
|
|
|
+ <span className="source-record-main">
|
|
|
+ <strong>{record.title}</strong>
|
|
|
+ <span className="source-record-meta">
|
|
|
+ <span className="source-badge">{record.badge}</span>
|
|
|
+ {record.subtitle}
|
|
|
+ </span>
|
|
|
+ {record.tags.length ? (
|
|
|
+ <span className="source-record-tags">
|
|
|
+ {record.tags.slice(0, 3).map((tag) => (
|
|
|
+ <span className={record.status === "verified" ? "mini-tag" : "mini-tag muted"} key={`${record.id}-${tag}`}>
|
|
|
+ {tag}
|
|
|
+ </span>
|
|
|
+ ))}
|
|
|
+ </span>
|
|
|
+ ) : null}
|
|
|
+ {record.metrics.length ? (
|
|
|
+ <span className="source-record-metrics">
|
|
|
+ {record.metrics.slice(0, 3).map((metric) => (
|
|
|
+ <span key={`${record.id}-${metric.label}`}>{metric.label} {metric.value}</span>
|
|
|
+ ))}
|
|
|
+ </span>
|
|
|
+ ) : null}
|
|
|
+ </span>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ );
|
|
|
+ })
|
|
|
+ ) : (
|
|
|
+ <div className="source-list-empty">
|
|
|
+ <strong>{config.emptyText}</strong>
|
|
|
+ <span>换一个筛选条件或搜索词试试。</span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </aside>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function PatternCardView({
|
|
|
+ card,
|
|
|
+ recordId,
|
|
|
+ variant = "list",
|
|
|
+}: {
|
|
|
+ card: PatternListCard;
|
|
|
+ recordId: string;
|
|
|
+ variant?: "list" | "detail";
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <span className={variant === "detail" ? "pattern-card-content detail" : "pattern-card-content"}>
|
|
|
+ <span className="pattern-card-title">{card.groupTitle}</span>
|
|
|
+ <span className="pattern-chip-list">
|
|
|
+ {card.chips.map((chip) => (
|
|
|
+ <span className={`pattern-chip ${chip.tone}`} key={`${recordId}-${chip.label}-${chip.meta}`}>
|
|
|
+ <strong>{chip.label}</strong>
|
|
|
+ <small>{chip.meta}</small>
|
|
|
+ </span>
|
|
|
+ ))}
|
|
|
+ </span>
|
|
|
+ <span className="pattern-card-bottom">
|
|
|
+ <span className="pattern-card-stats">
|
|
|
+ <span>支持度: <strong>{card.support}</strong></span>
|
|
|
+ <span>占比: <strong>{card.ratio}</strong></span>
|
|
|
+ <span>项数: <strong>{card.itemCount}</strong></span>
|
|
|
+ </span>
|
|
|
+ <span className="pattern-compare">比较</span>
|
|
|
+ </span>
|
|
|
+ </span>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function getPatternSeedCombinations(card: PatternListCard) {
|
|
|
+ const words = card.chips.map((chip) => chip.label);
|
|
|
+ if (words.length <= 1) {
|
|
|
+ return words;
|
|
|
+ }
|
|
|
+
|
|
|
+ const pairs = words.flatMap((word, index) => words.slice(index + 1).map((nextWord) => `${word} + ${nextWord}`));
|
|
|
+ return [...pairs, words.join(" + ")];
|
|
|
+}
|
|
|
+
|
|
|
+function PatternSeedPanel({ card }: { card: PatternListCard }) {
|
|
|
+ const seedCombinations = getPatternSeedCombinations(card);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <section className="pattern-seed-panel" aria-label="Pattern 策略种子">
|
|
|
+ <div className="pattern-seed-head">
|
|
|
+ <span>策略种子</span>
|
|
|
+ <strong>Pattern 词语排列组合</strong>
|
|
|
+ </div>
|
|
|
+ <div className="pattern-seed-list">
|
|
|
+ {seedCombinations.map((seed) => (
|
|
|
+ <span className="pattern-seed-chip" key={seed}>{seed}</span>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function SourceRecordDetail({
|
|
|
+ record,
|
|
|
+ config,
|
|
|
+}: {
|
|
|
+ record: DataSourceRecord | undefined;
|
|
|
+ config: DataSourcePanelConfig;
|
|
|
+}) {
|
|
|
+ if (!record) {
|
|
|
+ return (
|
|
|
+ <section className="source-detail-panel empty">
|
|
|
+ <h2>选择左侧记录查看详情</h2>
|
|
|
+ <p>当前筛选条件下没有可展示的 {config.title} 记录。</p>
|
|
|
+ </section>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ const patternListCard = config.laneId === "pattern" ? record.patternListCard : undefined;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <section className={record.status === "verified" ? "source-detail-panel" : "source-detail-panel placeholder"}>
|
|
|
+ <div className="source-detail-head">
|
|
|
+ <div>
|
|
|
+ <h2>{record.title}</h2>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="source-detail-body">
|
|
|
+ {patternListCard ? (
|
|
|
+ <section className="pattern-detail-panel" aria-label={`${config.title}详情`}>
|
|
|
+ <PatternCardView card={patternListCard} recordId={record.id} variant="detail" />
|
|
|
+ <PatternSeedPanel card={patternListCard} />
|
|
|
+ </section>
|
|
|
+ ) : (
|
|
|
+ <section className="source-evidence-area" aria-label={`${config.title}详情`}>
|
|
|
+ <div className="source-evidence-head">
|
|
|
+ <div>
|
|
|
+ <span>素材与种子</span>
|
|
|
+ <strong>{config.sourceObject}</strong>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="source-detail-content">
|
|
|
+ {record.note ? <p className="source-note">{record.note}</p> : null}
|
|
|
+ <div className="source-detail-block">
|
|
|
+ <div className="source-detail-block-head">
|
|
|
+ <span>素材原文</span>
|
|
|
+ <strong>证据现场</strong>
|
|
|
+ </div>
|
|
|
+ {record.materialRows.length ? <RowGrid rows={record.materialRows} /> : null}
|
|
|
+ {record.materialTable ? <DetailTableView table={record.materialTable} /> : null}
|
|
|
+ {!record.materialRows.length && !record.materialTable ? <p className="source-empty-note">基础信息已在左侧列表展示。</p> : null}
|
|
|
+ </div>
|
|
|
+ <div className="source-detail-block">
|
|
|
+ <div className="source-detail-block-head">
|
|
|
+ <span>策略种子</span>
|
|
|
+ <strong>后续 Query / 判断 / 游走可用输入</strong>
|
|
|
+ </div>
|
|
|
+ <RowGrid rows={record.seedRows} />
|
|
|
+ {record.seedTable ? <DetailTableView table={record.seedTable} /> : null}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function NodeDetailPanel({
|
|
|
+ selected,
|
|
|
+ onSelect,
|
|
|
+ view,
|
|
|
+}: {
|
|
|
+ selected: Selection;
|
|
|
+ onSelect: (next: Selection) => void;
|
|
|
+ view: ReturnType<typeof buildView>;
|
|
|
+}) {
|
|
|
+ const { lane, platform, stage, stageContent } = view;
|
|
|
+ const isVerified = stageContent.status === "verified";
|
|
|
+ const isWalkStage = stage.id === "walk";
|
|
|
+ const isAssetStage = stage.id === "asset";
|
|
|
+ const isLearningStage = stage.id === "learning";
|
|
|
+ const detailTitle = isAssetStage ? "资产清洗沉淀流程" : isLearningStage ? "策略学习方法" : stageContent.title;
|
|
|
+ const eyebrow = stageContent.eyebrow ?? "";
|
|
|
+ const judgmentRuleGroups = stage.id === "judge" ? stageContent.judgmentRuleGroups ?? [] : [];
|
|
|
+
|
|
|
+ return (
|
|
|
+ <section className={isVerified || isWalkStage ? "detail-panel verified" : "detail-panel placeholder"}>
|
|
|
+ {isWalkStage ? null : (
|
|
|
+ <div className="detail-head">
|
|
|
+ <div>
|
|
|
+ {eyebrow ? <p className="eyebrow">{eyebrow}</p> : null}
|
|
|
+ <h2>{detailTitle}</h2>
|
|
|
+ </div>
|
|
|
+ <div className="head-pills">
|
|
|
+ <span className={lane.tone === "need" ? "need-pill" : "open-pill"}>{lane.needType}</span>
|
|
|
+ <span className={isVerified ? "verified-pill" : "todo-pill"}>
|
|
|
+ {isAssetStage || isLearningStage ? "产品策略" : stageContent.badge ?? (isVerified ? "已实跑模拟" : "待补真实案例")}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {!isWalkStage && !isAssetStage && !isLearningStage && stageContent.note ? <p className="detail-note">{stageContent.note}</p> : null}
|
|
|
+
|
|
|
+ {isWalkStage ? (
|
|
|
+ <WalkStrategyWorkbench />
|
|
|
+ ) : isAssetStage ? (
|
|
|
+ <AssetFlowBoard flows={assetFlows} />
|
|
|
+ ) : isLearningStage ? (
|
|
|
+ <StrategyLearningBoard
|
|
|
+ experiments={learningExperiments}
|
|
|
+ groups={learningTraceGroups}
|
|
|
+ recommendations={learningRecommendations}
|
|
|
+ steps={learningMethodSteps}
|
|
|
+ />
|
|
|
+ ) : judgmentRuleGroups.length ? (
|
|
|
+ <JudgeRulePackBoard groups={judgmentRuleGroups} />
|
|
|
+ ) : (
|
|
|
+ <div className="section-stack">
|
|
|
+ {stageContent.sections.map((section) => (
|
|
|
+ <DetailSectionView
|
|
|
+ onPostSourceChange={(postSourceId) => onSelect({ ...selected, postSourceId })}
|
|
|
+ postSourceId={selected.postSourceId}
|
|
|
+ section={section}
|
|
|
+ key={section.title}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <LearningTraceStrip items={getLearningTraceItems(stage.id)} />
|
|
|
+ </section>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+const walkFilterGroups: Array<{
|
|
|
+ key: "platformId" | "startType" | "extensionAngle" | "decision";
|
|
|
+ label: string;
|
|
|
+ options: Array<{ id: WalkFilterKey; label: string }>;
|
|
|
+}> = [
|
|
|
+ {
|
|
|
+ key: "platformId",
|
|
|
+ label: "平台",
|
|
|
+ options: [
|
|
|
+ { id: "all", label: "全部" },
|
|
|
+ { id: "douyin", label: "抖音" },
|
|
|
+ { id: "xhs", label: "小红书" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: "startType",
|
|
|
+ label: "起点类型",
|
|
|
+ options: [
|
|
|
+ { id: "all", label: "全部" },
|
|
|
+ { id: "video", label: "视频" },
|
|
|
+ { id: "note", label: "笔记" },
|
|
|
+ { id: "author", label: "作者" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: "extensionAngle",
|
|
|
+ label: "扩展目标",
|
|
|
+ options: [
|
|
|
+ { id: "all", label: "全部" },
|
|
|
+ { id: "content", label: "视频/笔记" },
|
|
|
+ { id: "author", label: "作者" },
|
|
|
+ { id: "work", label: "作品" },
|
|
|
+ { id: "tag", label: "标签/话题" },
|
|
|
+ { id: "search", label: "搜索词" },
|
|
|
+ { id: "coauthor", label: "共创" },
|
|
|
+ { id: "similar", label: "相似作者" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: "decision",
|
|
|
+ label: "策略动作",
|
|
|
+ options: [
|
|
|
+ { id: "all", label: "全部" },
|
|
|
+ { id: "continue", label: "继续" },
|
|
|
+ { id: "accept", label: "入池" },
|
|
|
+ { id: "observe", label: "观察" },
|
|
|
+ { id: "reduce", label: "降预算" },
|
|
|
+ { id: "stop", label: "停止" },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+type WalkFilters = {
|
|
|
+ platformId: WalkFilterKey;
|
|
|
+ startType: WalkFilterKey;
|
|
|
+ extensionAngle: WalkFilterKey;
|
|
|
+ decision: WalkFilterKey;
|
|
|
+};
|
|
|
+
|
|
|
+const initialWalkFilters: WalkFilters = {
|
|
|
+ platformId: "all",
|
|
|
+ startType: "all",
|
|
|
+ extensionAngle: "all",
|
|
|
+ decision: "all",
|
|
|
+};
|
|
|
+
|
|
|
+const walkStrategyRows: WalkStrategyRow[] = [
|
|
|
+ {
|
|
|
+ id: "douyin-video-node",
|
|
|
+ platformId: "douyin",
|
|
|
+ platformLabel: "抖音",
|
|
|
+ startType: "video",
|
|
|
+ startLabel: "视频",
|
|
|
+ currentNode: "抖音视频节点",
|
|
|
+ optionIds: ["douyin-video-author", "douyin-text-tag-search", "douyin-related-search", "coauthor-explore"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "douyin-author-node",
|
|
|
+ platformId: "douyin",
|
|
|
+ platformLabel: "抖音",
|
|
|
+ startType: "author",
|
|
|
+ startLabel: "作者",
|
|
|
+ currentNode: "抖音作者节点",
|
|
|
+ optionIds: ["douyin-author-works"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "douyin-author-work-node",
|
|
|
+ platformId: "douyin",
|
|
|
+ platformLabel: "抖音",
|
|
|
+ startType: "author",
|
|
|
+ startLabel: "作者",
|
|
|
+ currentNode: "抖音作者作品节点",
|
|
|
+ optionIds: ["douyin-authorwork-candidate"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "xhs-note-node",
|
|
|
+ platformId: "xhs",
|
|
|
+ platformLabel: "小红书",
|
|
|
+ startType: "note",
|
|
|
+ startLabel: "笔记",
|
|
|
+ currentNode: "小红书笔记节点",
|
|
|
+ optionIds: ["xhs-note-author", "xhs-note-topic"],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "xhs-author-node",
|
|
|
+ platformId: "xhs",
|
|
|
+ platformLabel: "小红书",
|
|
|
+ startType: "author",
|
|
|
+ startLabel: "作者",
|
|
|
+ currentNode: "小红书作者节点",
|
|
|
+ optionIds: ["similar-author-explore"],
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+function WalkStrategyWorkbench() {
|
|
|
+ const [filters, setFilters] = useState<WalkFilters>(initialWalkFilters);
|
|
|
+ const [activeEventId, setActiveEventId] = useState(walkStrategyEvents[0]?.id ?? "");
|
|
|
+ const [selectedEventIdByRow, setSelectedEventIdByRow] = useState<Record<string, string>>({});
|
|
|
+ const filteredRows = useMemo(
|
|
|
+ () => walkStrategyRows.filter((row) => walkRowMatchesFilters(row, filters)),
|
|
|
+ [filters],
|
|
|
+ );
|
|
|
+ const rowEvents = useMemo(
|
|
|
+ () => filteredRows.map((row) => getSelectedWalkEventForRow(row, selectedEventIdByRow, filters)).filter(isWalkStrategyEvent),
|
|
|
+ [filteredRows, filters, selectedEventIdByRow],
|
|
|
+ );
|
|
|
+ const activeEvent = rowEvents.find((event) => event.id === activeEventId) ?? rowEvents[0] ?? walkStrategyEvents[0];
|
|
|
+
|
|
|
+ function updateFilter(key: keyof WalkFilters, value: WalkFilterKey) {
|
|
|
+ const nextFilters = { ...filters, [key]: value };
|
|
|
+ const nextRows = walkStrategyRows.filter((row) => walkRowMatchesFilters(row, nextFilters));
|
|
|
+ const nextEvents = nextRows.map((row) => getSelectedWalkEventForRow(row, selectedEventIdByRow, nextFilters)).filter(isWalkStrategyEvent);
|
|
|
+ setFilters(nextFilters);
|
|
|
+ if (!nextEvents.some((event) => event.id === activeEventId)) {
|
|
|
+ setActiveEventId(nextEvents[0]?.id ?? "");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateRowTarget(rowId: string, eventId: string) {
|
|
|
+ setSelectedEventIdByRow((current) => ({ ...current, [rowId]: eventId }));
|
|
|
+ setActiveEventId(eventId);
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="walk-workbench">
|
|
|
+ <section className="walk-filter-panel" aria-label="游走策略筛选">
|
|
|
+ {walkFilterGroups.map((group) => (
|
|
|
+ <div className="walk-filter-group" key={group.key}>
|
|
|
+ <span>{group.label}</span>
|
|
|
+ <div className="walk-filter-buttons" role="group" aria-label={group.label}>
|
|
|
+ {group.options.map((option) => (
|
|
|
+ <button
|
|
|
+ className={filters[group.key] === option.id ? "active" : ""}
|
|
|
+ key={`${group.key}-${option.id}`}
|
|
|
+ onClick={() => updateFilter(group.key, option.id)}
|
|
|
+ type="button"
|
|
|
+ >
|
|
|
+ {option.label}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section className="walk-main-grid">
|
|
|
+ <div className="walk-record-panel">
|
|
|
+ <div className="walk-record-head">
|
|
|
+ <div>
|
|
|
+ <span>游走策略表</span>
|
|
|
+ <h3>每行 = 一条可扩展策略边</h3>
|
|
|
+ </div>
|
|
|
+ <strong>{filteredRows.length} / {walkStrategyRows.length} 行</strong>
|
|
|
+ </div>
|
|
|
+ <WalkRecordTable
|
|
|
+ activeEventId={activeEvent?.id ?? ""}
|
|
|
+ filters={filters}
|
|
|
+ onSelectEvent={setActiveEventId}
|
|
|
+ onTargetChange={updateRowTarget}
|
|
|
+ rows={filteredRows}
|
|
|
+ selectedEventIdByRow={selectedEventIdByRow}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <WalkStrategyEdgeDetail event={activeEvent} />
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function getLearningTraceItems(stageId: PipelineStageId) {
|
|
|
+ return learningTraceItemsByStage[stageId] ?? [];
|
|
|
+}
|
|
|
+
|
|
|
+function LearningTraceStrip({ items }: { items: string[] }) {
|
|
|
+ return (
|
|
|
+ <section className="learning-trace-strip" aria-label="要策略学习的 trace 数据">
|
|
|
+ <strong>要策略学习的 trace 数据</strong>
|
|
|
+ <span>
|
|
|
+ {items.map((item) => (
|
|
|
+ <em key={item}>{item}</em>
|
|
|
+ ))}
|
|
|
+ </span>
|
|
|
+ </section>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function StrategyLearningBoard({
|
|
|
+ steps,
|
|
|
+ groups,
|
|
|
+ recommendations,
|
|
|
+ experiments,
|
|
|
+}: {
|
|
|
+ steps: LearningMethodStep[];
|
|
|
+ groups: LearningTraceGroup[];
|
|
|
+ recommendations: LearningRecommendation[];
|
|
|
+ experiments: LearningExperiment[];
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <div className="strategy-learning-board">
|
|
|
+ <section className="learning-method-panel">
|
|
|
+ <div className="learning-panel-head">
|
|
|
+ <span>怎么学习</span>
|
|
|
+ <strong>从一次结果看到下次怎么改</strong>
|
|
|
+ </div>
|
|
|
+ <div className="learning-method-steps">
|
|
|
+ {steps.map((step, index) => (
|
|
|
+ <article className="learning-method-step" key={step.title}>
|
|
|
+ <span>{String(index + 1).padStart(2, "0")}</span>
|
|
|
+ <strong>{step.title}</strong>
|
|
|
+ <small>{step.output}</small>
|
|
|
+ </article>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section className="learning-trace-panel">
|
|
|
+ <div className="learning-panel-head">
|
|
|
+ <span>学哪些数据</span>
|
|
|
+ <strong>每轮复盘只看这些关键数据</strong>
|
|
|
+ </div>
|
|
|
+ <div className="learning-trace-grid">
|
|
|
+ {groups.map((group) => (
|
|
|
+ <article className="learning-trace-card" key={group.title}>
|
|
|
+ <strong>{group.title}</strong>
|
|
|
+ <div>
|
|
|
+ {group.items.map((item) => (
|
|
|
+ <span key={`${group.title}-${item}`}>{item}</span>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </article>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section className="learning-recommendation-panel">
|
|
|
+ <div className="learning-panel-head">
|
|
|
+ <span>学完改什么</span>
|
|
|
+ <strong>把发现变成下一轮动作</strong>
|
|
|
+ </div>
|
|
|
+ <div className="learning-recommendation-table">
|
|
|
+ <div className="learning-recommendation-head">
|
|
|
+ <span>学习对象</span>
|
|
|
+ <span>发现</span>
|
|
|
+ <span>建议</span>
|
|
|
+ <span>影响阶段</span>
|
|
|
+ </div>
|
|
|
+ {recommendations.map((recommendation) => (
|
|
|
+ <div className="learning-recommendation-row" key={`${recommendation.target}-${recommendation.finding}`}>
|
|
|
+ <strong>{recommendation.target}</strong>
|
|
|
+ <span>{recommendation.finding}</span>
|
|
|
+ <span>{recommendation.suggestion}</span>
|
|
|
+ <em>{recommendation.impact}</em>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section className="learning-experiment-panel">
|
|
|
+ <div className="learning-panel-head">
|
|
|
+ <span>下一轮怎么试</span>
|
|
|
+ <strong>小范围验证,好的再回写</strong>
|
|
|
+ </div>
|
|
|
+ <div className="learning-experiment-grid">
|
|
|
+ {experiments.map((experiment) => (
|
|
|
+ <article className="learning-experiment-card" key={experiment.title}>
|
|
|
+ <strong>{experiment.title}</strong>
|
|
|
+ <span>改动:{experiment.change}</span>
|
|
|
+ <small>观察:{experiment.observe}</small>
|
|
|
+ </article>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function AssetFlowBoard({ flows }: { flows: AssetFlow[] }) {
|
|
|
+ return (
|
|
|
+ <div className="asset-flow-board" aria-label="资产清洗沉淀流程">
|
|
|
+ {flows.map((flow) => (
|
|
|
+ <section className="asset-flow-row" key={flow.id}>
|
|
|
+ <div className="asset-flow-title">
|
|
|
+ <span>{flow.target}</span>
|
|
|
+ <strong>{flow.title}</strong>
|
|
|
+ </div>
|
|
|
+ <div className="asset-flow-steps">
|
|
|
+ {flow.steps.map((step, index) => (
|
|
|
+ <div className="asset-flow-step" key={`${flow.id}-${step}`}>
|
|
|
+ <span>{String(index + 1).padStart(2, "0")}</span>
|
|
|
+ <strong>{step}</strong>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function getWalkEventById(eventId: string) {
|
|
|
+ return walkStrategyEvents.find((event) => event.id === eventId);
|
|
|
+}
|
|
|
+
|
|
|
+function isWalkStrategyEvent(event: WalkStrategyEvent | undefined): event is WalkStrategyEvent {
|
|
|
+ return Boolean(event);
|
|
|
+}
|
|
|
+
|
|
|
+function getWalkEventsForRow(row: WalkStrategyRow) {
|
|
|
+ return row.optionIds.map(getWalkEventById).filter(isWalkStrategyEvent);
|
|
|
+}
|
|
|
+
|
|
|
+function walkEventMatchesEdgeFilters(event: WalkStrategyEvent, filters: WalkFilters) {
|
|
|
+ return (
|
|
|
+ (filters.extensionAngle === "all" || event.extensionAngle === filters.extensionAngle) &&
|
|
|
+ (filters.decision === "all" || event.decision === filters.decision)
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function walkRowMatchesFilters(row: WalkStrategyRow, filters: WalkFilters) {
|
|
|
+ const rowEvents = getWalkEventsForRow(row);
|
|
|
+ return (
|
|
|
+ (filters.platformId === "all" || row.platformId === filters.platformId) &&
|
|
|
+ (filters.startType === "all" || row.startType === filters.startType) &&
|
|
|
+ rowEvents.some((event) => walkEventMatchesEdgeFilters(event, filters))
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function getSelectableWalkEventsForRow(row: WalkStrategyRow, filters: WalkFilters) {
|
|
|
+ return getWalkEventsForRow(row).filter((event) => walkEventMatchesEdgeFilters(event, filters));
|
|
|
+}
|
|
|
+
|
|
|
+function getSelectedWalkEventForRow(
|
|
|
+ row: WalkStrategyRow,
|
|
|
+ selectedEventIdByRow: Record<string, string>,
|
|
|
+ filters: WalkFilters,
|
|
|
+) {
|
|
|
+ const options = getSelectableWalkEventsForRow(row, filters);
|
|
|
+ const selectedEvent = getWalkEventById(selectedEventIdByRow[row.id] ?? "");
|
|
|
+ if (selectedEvent && options.some((event) => event.id === selectedEvent.id)) {
|
|
|
+ return selectedEvent;
|
|
|
+ }
|
|
|
+
|
|
|
+ return options[0];
|
|
|
+}
|
|
|
+
|
|
|
+function WalkRecordTable({
|
|
|
+ rows,
|
|
|
+ activeEventId,
|
|
|
+ filters,
|
|
|
+ onSelectEvent,
|
|
|
+ onTargetChange,
|
|
|
+ selectedEventIdByRow,
|
|
|
+}: {
|
|
|
+ rows: WalkStrategyRow[];
|
|
|
+ activeEventId: string;
|
|
|
+ filters: WalkFilters;
|
|
|
+ onSelectEvent: (eventId: string) => void;
|
|
|
+ onTargetChange: (rowId: string, eventId: string) => void;
|
|
|
+ selectedEventIdByRow: Record<string, string>;
|
|
|
+}) {
|
|
|
+ if (!rows.length) {
|
|
|
+ return (
|
|
|
+ <div className="walk-empty">
|
|
|
+ <strong>没有匹配的游走策略</strong>
|
|
|
+ <span>换一个平台、起点类型或扩展目标筛选。</span>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="walk-table-wrap">
|
|
|
+ <table className="walk-record-table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>平台 / 起点适用</th>
|
|
|
+ <th>可扩展到</th>
|
|
|
+ <th>策略边</th>
|
|
|
+ <th>可发生深度</th>
|
|
|
+ <th>数据状态</th>
|
|
|
+ <th>规则包</th>
|
|
|
+ <th>沉淀对象</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {rows.map((row) => {
|
|
|
+ const options = getSelectableWalkEventsForRow(row, filters);
|
|
|
+ const event = getSelectedWalkEventForRow(row, selectedEventIdByRow, filters) ?? options[0];
|
|
|
+ if (!event) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <tr
|
|
|
+ className={activeEventId === event.id ? "active" : ""}
|
|
|
+ key={row.id}
|
|
|
+ onClick={() => onSelectEvent(event.id)}
|
|
|
+ >
|
|
|
+ <td>
|
|
|
+ <div className="walk-platform-cell">
|
|
|
+ <strong>{row.platformLabel}</strong>
|
|
|
+ <span>{row.startLabel}起点适用</span>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <select
|
|
|
+ className="walk-target-select"
|
|
|
+ onChange={(selectEvent) => {
|
|
|
+ selectEvent.stopPropagation();
|
|
|
+ onTargetChange(row.id, selectEvent.currentTarget.value);
|
|
|
+ }}
|
|
|
+ onClick={(clickEvent) => clickEvent.stopPropagation()}
|
|
|
+ value={event.id}
|
|
|
+ >
|
|
|
+ {options.map((option) => (
|
|
|
+ <option key={option.id} value={option.id}>
|
|
|
+ {option.extensionLabel}
|
|
|
+ </option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ <small>{event.toNode}</small>
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <strong className="walk-edge">{event.edge}</strong>
|
|
|
+ <small>{event.fromNode} {"->"} {event.toNode}</small>
|
|
|
+ </td>
|
|
|
+ <td>{event.depth}</td>
|
|
|
+ <td><WalkStatusBadge status={event.evidenceStatus} /></td>
|
|
|
+ <td>
|
|
|
+ <span className="walk-rule-chip">{event.rulePackName}</span>
|
|
|
+ <small>{event.rulePackId}</small>
|
|
|
+ </td>
|
|
|
+ <td><small>{event.output}</small></td>
|
|
|
+ </tr>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function WalkStrategyEdgeDetail({ event }: { event: WalkStrategyEvent | undefined }) {
|
|
|
+ if (!event) {
|
|
|
+ return (
|
|
|
+ <aside className="walk-trace-panel empty">
|
|
|
+ <h3>选择一条策略边查看说明</h3>
|
|
|
+ </aside>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <aside className="walk-trace-panel" aria-label={`${event.edge} 策略边说明`}>
|
|
|
+ <div className="walk-trace-head">
|
|
|
+ <div>
|
|
|
+ <span>策略边说明</span>
|
|
|
+ <h3>{event.edge}</h3>
|
|
|
+ </div>
|
|
|
+ <WalkStatusBadge status={event.evidenceStatus} />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="walk-mini-flow" aria-label="局部流程">
|
|
|
+ <div className="walk-flow-node start">
|
|
|
+ <span>来源节点</span>
|
|
|
+ <strong>{event.fromNode}</strong>
|
|
|
+ </div>
|
|
|
+ <div className="walk-flow-arrow">
|
|
|
+ <span>{event.rulePackName}</span>
|
|
|
+ </div>
|
|
|
+ <div className="walk-flow-node end">
|
|
|
+ <span>可扩展到</span>
|
|
|
+ <strong>{event.toNode}</strong>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <section className="walk-trace-section">
|
|
|
+ <h4>适用与沉淀</h4>
|
|
|
+ <WalkFactStack rows={event.detailRows} />
|
|
|
+ </section>
|
|
|
+ <section className="walk-trace-section">
|
|
|
+ <h4>条件判断:继续 / 停止 / 沉淀</h4>
|
|
|
+ <WalkFactStack rows={[
|
|
|
+ { label: "继续", value: event.continueCondition },
|
|
|
+ { label: "停止", value: event.stopCondition },
|
|
|
+ { label: "沉淀", value: event.output },
|
|
|
+ ]} />
|
|
|
+ </section>
|
|
|
+ </aside>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function WalkStatusBadge({ status }: { status: WalkEvidenceStatus }) {
|
|
|
+ return (
|
|
|
+ <span className={status === "verified" ? "walk-status verified" : "walk-status pending"}>
|
|
|
+ {status === "verified" ? "已验证" : "待验证"}
|
|
|
+ </span>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function WalkFactStack({ rows }: { rows: TextRow[] }) {
|
|
|
+ return (
|
|
|
+ <div className="walk-fact-stack">
|
|
|
+ {rows.map((row) => (
|
|
|
+ <div className="walk-fact" key={`${row.label}-${row.value}`}>
|
|
|
+ <span>{row.label}</span>
|
|
|
+ <strong>{row.value}</strong>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function JudgeRulePackBoard({ groups }: { groups: JudgmentRuleGroup[] }) {
|
|
|
+ const [activeRule, setActiveRule] = useState<JudgmentRulePack | null>(null);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className={activeRule ? "judge-rule-board drawer-open" : "judge-rule-board"}>
|
|
|
+ <div className="judge-rule-groups">
|
|
|
+ {groups.map((group) => (
|
|
|
+ <section className="judge-rule-group" key={group.id}>
|
|
|
+ <div className="judge-rule-group-head">
|
|
|
+ <div>
|
|
|
+ <span>{group.rules.length} 条规则包</span>
|
|
|
+ <h3>{group.title}</h3>
|
|
|
+ </div>
|
|
|
+ <p>{group.summary}</p>
|
|
|
+ </div>
|
|
|
+ <div className="judge-rule-row" aria-label={group.title}>
|
|
|
+ {group.rules.map((rule) => (
|
|
|
+ <button
|
|
|
+ aria-pressed={activeRule?.id === rule.id}
|
|
|
+ className={activeRule?.id === rule.id ? "judge-rule-card active" : "judge-rule-card"}
|
|
|
+ key={rule.id}
|
|
|
+ onClick={() => setActiveRule(rule)}
|
|
|
+ type="button"
|
|
|
+ >
|
|
|
+ <span className="judge-rule-kicker">{rule.scoreLogic}</span>
|
|
|
+ <strong>{rule.title}</strong>
|
|
|
+ <p>{rule.summary}</p>
|
|
|
+ <span className="judge-rule-applies">{rule.appliesTo}</span>
|
|
|
+ <div className="judge-rule-score-line">
|
|
|
+ <span>硬门槛 {rule.hardGates.length}</span>
|
|
|
+ <span>评分维度 {rule.scoring.length}</span>
|
|
|
+ </div>
|
|
|
+ <span className="judge-rule-passline">{rule.passLine}</span>
|
|
|
+ <div className="judge-rule-tags">
|
|
|
+ {rule.signals.slice(0, 3).map((signal) => (
|
|
|
+ <span key={signal}>{signal}</span>
|
|
|
+ ))}
|
|
|
+ <span>{rule.signals.length} 信号</span>
|
|
|
+ </div>
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {activeRule ? (
|
|
|
+ <aside className="judge-rule-drawer" aria-label={`${activeRule.title} 规则细则`}>
|
|
|
+ <div className="judge-rule-drawer-head">
|
|
|
+ <div>
|
|
|
+ <span>{activeRule.scoreLogic}</span>
|
|
|
+ <h3>{activeRule.title}</h3>
|
|
|
+ <p>{activeRule.summary}</p>
|
|
|
+ </div>
|
|
|
+ <button className="judge-rule-drawer-close" onClick={() => setActiveRule(null)} type="button">
|
|
|
+ 关闭
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div className="judge-rule-drawer-body">
|
|
|
+ <section className="judge-rule-detail-section">
|
|
|
+ <h4>目标</h4>
|
|
|
+ <RowGrid rows={[
|
|
|
+ { label: "适用对象", value: activeRule.appliesTo },
|
|
|
+ { label: "通过线", value: activeRule.passLine },
|
|
|
+ ]} />
|
|
|
+ </section>
|
|
|
+ <section className="judge-rule-detail-section">
|
|
|
+ <h4>输入信号</h4>
|
|
|
+ <div className="judge-rule-chip-list">
|
|
|
+ {activeRule.signals.map((signal) => (
|
|
|
+ <span key={signal}>{signal}</span>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ <RuleFactStack rows={activeRule.hardGates} title="硬门槛" />
|
|
|
+ <RuleScoreStack metrics={activeRule.scoring} />
|
|
|
+ <RuleFactStack rows={activeRule.thresholds} title="评分阈值" />
|
|
|
+ <RuleFactStack rows={activeRule.actionPolicy} title="动作决策" />
|
|
|
+ <RuleFactStack rows={activeRule.outputs} title="输出字段" />
|
|
|
+ <RuleFactStack rows={activeRule.evidence} title="证据字段" />
|
|
|
+ <RuleFactStack rows={activeRule.example} title="示例" />
|
|
|
+ </div>
|
|
|
+ </aside>
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function RuleScoreStack({ metrics }: { metrics: JudgmentScoreMetric[] }) {
|
|
|
+ return (
|
|
|
+ <section className="judge-rule-detail-section">
|
|
|
+ <h4>软评分体系</h4>
|
|
|
+ <div className="judge-score-stack">
|
|
|
+ {metrics.map((metric) => (
|
|
|
+ <article className="judge-score-metric" key={`${metric.dimension}-${metric.weight}`}>
|
|
|
+ <div>
|
|
|
+ <strong>{metric.dimension}</strong>
|
|
|
+ <span>{metric.weight}</span>
|
|
|
+ </div>
|
|
|
+ <p>{metric.highScore}</p>
|
|
|
+ <small>{metric.evidence}</small>
|
|
|
+ </article>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function RuleFactStack({ title, rows }: { title: string; rows: TextRow[] }) {
|
|
|
+ return (
|
|
|
+ <section className="judge-rule-detail-section">
|
|
|
+ <h4>{title}</h4>
|
|
|
+ <div className="judge-rule-fact-stack">
|
|
|
+ {rows.map((row) => (
|
|
|
+ <div className="judge-rule-fact" key={`${title}-${row.label}-${row.value}`}>
|
|
|
+ <span>{row.label}</span>
|
|
|
+ <strong>{row.value}</strong>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function DetailSectionView({
|
|
|
+ section,
|
|
|
+ postSourceId,
|
|
|
+ onPostSourceChange,
|
|
|
+}: {
|
|
|
+ section: DetailSection;
|
|
|
+ postSourceId: PostSourceId;
|
|
|
+ onPostSourceChange: (next: PostSourceId) => void;
|
|
|
+}) {
|
|
|
+ const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
+ const className = ["detail-section", section.tone ?? "plain"].join(" ");
|
|
|
+
|
|
|
+ if (section.postSourceControl) {
|
|
|
+ return (
|
|
|
+ <article className={className}>
|
|
|
+ <h3>{section.title}</h3>
|
|
|
+ <PostSourceControl selectedId={postSourceId} onChange={onPostSourceChange} />
|
|
|
+ {section.rows ? <RowGrid rows={section.rows} /> : null}
|
|
|
+ {section.images ? <ImageStrip images={section.images} /> : null}
|
|
|
+ {section.body ? <p className="section-body evidence-summary">{section.body}</p> : null}
|
|
|
+ </article>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <article className={className}>
|
|
|
+ <h3>{section.title}</h3>
|
|
|
+ {section.body ? <p className="section-body">{section.body}</p> : null}
|
|
|
+ {section.modalTable ? (
|
|
|
+ <button className="modal-button" onClick={() => setIsModalOpen(true)} type="button">
|
|
|
+ 查看完整输入素材表
|
|
|
+ </button>
|
|
|
+ ) : null}
|
|
|
+ {section.queryWorkshop ? <QueryWorkshopView workshop={section.queryWorkshop} /> : null}
|
|
|
+ {section.rows ? <RowGrid rows={section.rows} /> : null}
|
|
|
+ {section.chips ? <ChipList chips={section.chips} /> : null}
|
|
|
+ {section.table ? <DetailTableView table={section.table} /> : null}
|
|
|
+ {section.images ? <ImageStrip images={section.images} /> : null}
|
|
|
+ {isModalOpen && section.modalTable ? (
|
|
|
+ <div className="modal-backdrop" role="presentation" onClick={() => setIsModalOpen(false)}>
|
|
|
+ <div
|
|
|
+ className="modal-panel"
|
|
|
+ role="dialog"
|
|
|
+ aria-modal="true"
|
|
|
+ aria-label="完整输入素材表"
|
|
|
+ onClick={(event) => event.stopPropagation()}
|
|
|
+ >
|
|
|
+ <div className="modal-head">
|
|
|
+ <div>
|
|
|
+ <span>Case Query</span>
|
|
|
+ <strong>完整输入素材表</strong>
|
|
|
+ </div>
|
|
|
+ <button className="modal-close" onClick={() => setIsModalOpen(false)} type="button">
|
|
|
+ 关闭
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <DetailTableView table={section.modalTable} />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ </article>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function PostSourceControl({
|
|
|
+ selectedId,
|
|
|
+ onChange,
|
|
|
+}: {
|
|
|
+ selectedId: PostSourceId;
|
|
|
+ onChange: (next: PostSourceId) => void;
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <label className="post-source-control">
|
|
|
+ <span>帖子来源</span>
|
|
|
+ <select value={selectedId} onChange={(event) => onChange(event.target.value as PostSourceId)}>
|
|
|
+ {postSourceOptions.map((source) => (
|
|
|
+ <option value={source.id} key={source.id}>{source.label}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ </label>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function TracePanel({ items }: { items: string[] }) {
|
|
|
+ if (!items.length) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <section className="trace-panel">
|
|
|
+ <h3>策略 Trace</h3>
|
|
|
+ <div className="trace-list">
|
|
|
+ {items.map((item) => (
|
|
|
+ <span className="trace-item" key={item}>{item}</span>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function QueryWorkshopView({ workshop }: { workshop: QueryWorkshop }) {
|
|
|
+ const [activePrompt, setActivePrompt] = useState<PromptBlock | null>(null);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="query-workshop">
|
|
|
+ <section className="query-column evidence-column">
|
|
|
+ <h4>Query 输入素材</h4>
|
|
|
+ <div className="query-evidence-list">
|
|
|
+ {workshop.evidence.map((item) => (
|
|
|
+ <article className="query-evidence-card" key={`${item.kind}-${item.title}`}>
|
|
|
+ <span className="evidence-kind">{item.kind}</span>
|
|
|
+ <strong>{item.title}</strong>
|
|
|
+ <small>{item.source}</small>
|
|
|
+ </article>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section className="query-column prompt-column">
|
|
|
+ <h4>Prompt</h4>
|
|
|
+ <div className="prompt-stack">
|
|
|
+ {workshop.prompts.map((prompt) => (
|
|
|
+ <article className="prompt-card" key={prompt.title}>
|
|
|
+ <div className="prompt-card-head">
|
|
|
+ <strong>{prompt.title}</strong>
|
|
|
+ {prompt.fullContent ? (
|
|
|
+ <button className="prompt-expand-button" onClick={() => setActivePrompt(prompt)} type="button">
|
|
|
+ 展开
|
|
|
+ </button>
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ <p>{prompt.content}</p>
|
|
|
+ </article>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section className="query-column result-column">
|
|
|
+ <h4>Query 组合</h4>
|
|
|
+ <div className="query-group-stack">
|
|
|
+ {workshop.queryGroups.map((group) => (
|
|
|
+ <article className="query-group-card" key={group.evidenceTitle}>
|
|
|
+ <div className="query-group-head">
|
|
|
+ <strong>{group.evidenceTitle}</strong>
|
|
|
+ <span>{group.queries.length} 个</span>
|
|
|
+ </div>
|
|
|
+ <div className="query-list">
|
|
|
+ {group.queries.map((query) => (
|
|
|
+ <span
|
|
|
+ className={query === workshop.selectedQuery ? "query-chip selected" : "query-chip"}
|
|
|
+ key={query}
|
|
|
+ >
|
|
|
+ {query}
|
|
|
+ </span>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ <p>{group.note}</p>
|
|
|
+ </article>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ {activePrompt ? (
|
|
|
+ <div className="modal-backdrop" role="presentation" onClick={() => setActivePrompt(null)}>
|
|
|
+ <div
|
|
|
+ className="modal-panel prompt-modal"
|
|
|
+ role="dialog"
|
|
|
+ aria-modal="true"
|
|
|
+ aria-label={activePrompt.title}
|
|
|
+ onClick={(event) => event.stopPropagation()}
|
|
|
+ >
|
|
|
+ <div className="modal-head">
|
|
|
+ <div>
|
|
|
+ <span>完整 Prompt</span>
|
|
|
+ <strong>{activePrompt.title}</strong>
|
|
|
+ </div>
|
|
|
+ <button className="modal-close" onClick={() => setActivePrompt(null)} type="button">
|
|
|
+ 关闭
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <pre className="prompt-modal-content">{activePrompt.fullContent ?? activePrompt.content}</pre>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function RowGrid({ rows }: { rows: TextRow[] }) {
|
|
|
+ return (
|
|
|
+ <div className="row-grid">
|
|
|
+ {rows.map((row) => (
|
|
|
+ <div className={row.label === "下层特征" ? "fact-card seed-primary" : "fact-card"} key={`${row.label}-${row.value}`}>
|
|
|
+ <span>{row.label}</span>
|
|
|
+ <strong>{row.value}</strong>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function ChipList({ chips }: { chips: string[] }) {
|
|
|
+ return (
|
|
|
+ <div className="chip-list">
|
|
|
+ {chips.map((chip) => (
|
|
|
+ <span className="chip" key={chip}>{chip}</span>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function DetailTableView({ table }: { table: DetailTable }) {
|
|
|
+ return (
|
|
|
+ <div className="table-wrap">
|
|
|
+ <table>
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ {table.columns.map((column) => <th key={column}>{column}</th>)}
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {table.rows.map((row, index) => (
|
|
|
+ <tr key={row.join("-") || index}>
|
|
|
+ {row.map((cell, cellIndex) => <td key={`${index}-${cellIndex}`}>{cell}</td>)}
|
|
|
+ </tr>
|
|
|
+ ))}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function ImageStrip({ images }: { images: string[] }) {
|
|
|
+ return (
|
|
|
+ <div className="image-strip">
|
|
|
+ {images.map((src) => (
|
|
|
+ <img src={src} alt="case evidence" key={src} />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+export default App;
|