|
|
@@ -36,6 +36,7 @@ import type {
|
|
|
TimelineResponse,
|
|
|
} from "@/lib/api/types";
|
|
|
import {compactValue, statusLabel } from "@/lib/status/status";
|
|
|
+import { platformLabel } from "@/lib/platform/content";
|
|
|
|
|
|
type DashboardData = {
|
|
|
dashboard: DashboardResponse;
|
|
|
@@ -145,7 +146,7 @@ export function RunDashboardPage({ runId }: { runId: string }) {
|
|
|
activeStage={activeStage}
|
|
|
onSelect={setActiveStage}
|
|
|
/>
|
|
|
- <TimelineSummaryStrip runId={runId} timeline={data.timeline} />
|
|
|
+ <TimelineSummaryStrip runId={runId} timeline={data.timeline} contentItems={data.contentItems} />
|
|
|
<StagePanel
|
|
|
activeStage={activeStage}
|
|
|
data={data}
|
|
|
@@ -161,11 +162,36 @@ export function RunDashboardPage({ runId }: { runId: string }) {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-function TimelineSummaryStrip({ runId, timeline }: { runId: string; timeline: TimelineResponse }) {
|
|
|
+function TimelineSummaryStrip({
|
|
|
+ runId,
|
|
|
+ timeline,
|
|
|
+ contentItems
|
|
|
+}: {
|
|
|
+ runId: string;
|
|
|
+ timeline: TimelineResponse;
|
|
|
+ contentItems: ContentItemsResponse;
|
|
|
+}) {
|
|
|
const summary = timeline.summary;
|
|
|
- const decodeLine = Object.entries(summary?.decode_status_counts || {})
|
|
|
+ // V3:Gemini 判定状态计数(来自 content items 的 pattern_match_result.judge_status)。
|
|
|
+ const judgeCounts: Record<string, number> = {};
|
|
|
+ let hasJudge = false;
|
|
|
+ contentItems.items.forEach((item) => {
|
|
|
+ const pmr = item.pattern_match_result as Record<string, unknown> | undefined;
|
|
|
+ const js = pmr?.judge_status;
|
|
|
+ if (typeof js === "string" && js) {
|
|
|
+ hasJudge = true;
|
|
|
+ judgeCounts[js] = (judgeCounts[js] || 0) + 1;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ const judgeLine = Object.entries(judgeCounts)
|
|
|
.map(([key, value]) => `${statusLabel(key)} ${value}`)
|
|
|
.join(" / ");
|
|
|
+ // 配额截断事件(run_events 通道)。
|
|
|
+ const quotaEvent = timeline.items.find((it) => it.event_type === "gemini_quota_exhausted");
|
|
|
+ // V2 历史字段:仅老 run 回看时非空。
|
|
|
+ const decodeLine = Object.entries(summary?.decode_status_counts || {})
|
|
|
+ .map(([key, value]) => `${key} ${value}`)
|
|
|
+ .join(" / ");
|
|
|
return (
|
|
|
<div className="timeline-strip">
|
|
|
<span>
|
|
|
@@ -173,7 +199,9 @@ function TimelineSummaryStrip({ runId, timeline }: { runId: string; timeline: Ti
|
|
|
</span>
|
|
|
<span>query 失败 {summary?.query_failure_count ?? 0}</span>
|
|
|
<span>平台限流 {summary?.platform_rate_limited_count ?? 0}</span>
|
|
|
- <span>decode {decodeLine || "无"}</span>
|
|
|
+ {hasJudge ? <span>Gemini 判定 {judgeLine}</span> : null}
|
|
|
+ {quotaEvent ? <span className="strip-warn">⚠ Gemini 配额截断</span> : null}
|
|
|
+ {decodeLine ? <span className="strip-muted">decode(历史) {decodeLine}</span> : null}
|
|
|
<span>事件 {timeline.total} 条</span>
|
|
|
<Link className="text-button" href={`/runs/${encodeURIComponent(runId)}/timeline`}>
|
|
|
查看完整时间线 / 日志
|
|
|
@@ -538,19 +566,22 @@ function StagePanel({
|
|
|
);
|
|
|
}
|
|
|
if (activeStage === "judge") {
|
|
|
+ const itemByContentId = new Map<string, Record<string, unknown>>();
|
|
|
+ data.contentItems.items.forEach((it) => {
|
|
|
+ const id = String(it.platform_content_id || "");
|
|
|
+ if (id) itemByContentId.set(id, it);
|
|
|
+ });
|
|
|
return (
|
|
|
<BusinessSection title="策略包如何施加到内容判断" icon={<ShieldCheck size={17} />}>
|
|
|
<ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "judge")} />
|
|
|
<div className="rule-chain">
|
|
|
- <span>内容</span>
|
|
|
+ <span>视频</span>
|
|
|
<ChevronRight size={16} />
|
|
|
- <span>EvidenceBundle</span>
|
|
|
- <ChevronRight size={16} />
|
|
|
- <span>Content Rule Pack V1</span>
|
|
|
+ <span>Gemini 直读判定</span>
|
|
|
<ChevronRight size={16} />
|
|
|
<span>硬性筛选门槛</span>
|
|
|
<ChevronRight size={16} />
|
|
|
- <span>Scorecard</span>
|
|
|
+ <span>Scorecard(相关性 + 平台热度)</span>
|
|
|
<ChevronRight size={16} />
|
|
|
<span>Decision</span>
|
|
|
</div>
|
|
|
@@ -558,6 +589,7 @@ function StagePanel({
|
|
|
{data.dashboard.rule_application_summary.length ? data.dashboard.rule_application_summary.map((item, index) => (
|
|
|
<RuleApplicationCard
|
|
|
item={item}
|
|
|
+ contentItem={itemByContentId.get(String(item.platform_content_id || ""))}
|
|
|
key={item.decision_id || index}
|
|
|
onOpen={() =>
|
|
|
onOpenDrawer({
|
|
|
@@ -738,19 +770,25 @@ function ConclusionBody({ stage }: { stage?: StageConclusion }) {
|
|
|
|
|
|
function RuleApplicationCard({
|
|
|
item,
|
|
|
+ contentItem,
|
|
|
onOpen
|
|
|
}: {
|
|
|
item: RuleApplicationSummary;
|
|
|
+ contentItem?: Record<string, unknown>;
|
|
|
onOpen: () => void;
|
|
|
}) {
|
|
|
const title = item.content_title && item.content_title !== item.platform_content_id ? item.content_title : "视频内容";
|
|
|
+ const platform = contentItem ? String(contentItem.platform || "") : "";
|
|
|
+ const pmr = (contentItem?.pattern_match_result as Record<string, unknown>) || null;
|
|
|
+ const idLabel = platform ? `${platformLabel(platform)}视频 ID` : "视频 ID";
|
|
|
return (
|
|
|
<div className="rule-application-card">
|
|
|
<div className="rule-card-heading">
|
|
|
<span>判断对象</span>
|
|
|
<strong>{compactValue(title)}</strong>
|
|
|
- <small>抖音视频 ID:{compactValue(item.platform_content_id)}</small>
|
|
|
+ <small>{idLabel}:{compactValue(item.platform_content_id)}</small>
|
|
|
</div>
|
|
|
+ {pmr ? <GeminiJudgeRow pmr={pmr} /> : null}
|
|
|
<div className="rule-application-flow">
|
|
|
<span>
|
|
|
规则包:{rulePackLabel(item.rule_pack)}
|
|
|
@@ -769,6 +807,7 @@ function RuleApplicationCard({
|
|
|
<small>{effectStatusLabel(item.content_effect_status)}</small>
|
|
|
</span>
|
|
|
</div>
|
|
|
+ <ScorecardRow contentItem={contentItem} />
|
|
|
<div className="business-alert compact">
|
|
|
<ShieldCheck size={14} />
|
|
|
<span>主原因:{reasonLabel(item.decision_reason_code || item.primary_reason)}</span>
|
|
|
@@ -780,6 +819,64 @@ function RuleApplicationCard({
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+function GeminiJudgeRow({ pmr }: { pmr: Record<string, unknown> }) {
|
|
|
+ const fit = pmr.fit_senior_50plus;
|
|
|
+ const conf = pmr.fit_confidence;
|
|
|
+ const rel = pmr.relevance_score;
|
|
|
+ const status = String(pmr.judge_status || "");
|
|
|
+ const reason = String(pmr.reason || "");
|
|
|
+ const quota = status === "gemini_quota_exhausted";
|
|
|
+ // 仅 V3 Gemini 判定有这些字段;V2 run 无任何判定信号则整行不渲染。
|
|
|
+ const hasV3Signal = status !== "" || typeof fit === "boolean" || rel != null || conf != null;
|
|
|
+ if (!hasV3Signal) return null;
|
|
|
+ return (
|
|
|
+ <div className={`gemini-judge-row${quota ? " quota" : ""}`}>
|
|
|
+ <span className="gemini-judge-tag">Gemini 判定</span>
|
|
|
+ {quota ? (
|
|
|
+ <span className="gemini-judge-quota">⚠ 配额截断,未判定</span>
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ <span>适合 50+:{fit === true ? "是" : fit === false ? "否" : "—"}</span>
|
|
|
+ <span>置信度:{conf == null ? "—" : compactValue(conf)}</span>
|
|
|
+ <span>相关性:{rel == null ? "—" : compactValue(rel)}</span>
|
|
|
+ <span>状态:{statusLabel(status)}</span>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {reason ? <small className="gemini-judge-reason">{reason}</small> : null}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function ScorecardRow({ contentItem }: { contentItem?: Record<string, unknown> }) {
|
|
|
+ const decision = (contentItem?.rule_decision as Record<string, unknown>) || null;
|
|
|
+ const scorecard = (decision?.scorecard as Record<string, unknown>) || null;
|
|
|
+ const dims = (scorecard?.dimensions as Array<Record<string, unknown>>) || [];
|
|
|
+ if (!dims.length) return null;
|
|
|
+ const DEPRECATED = new Set([
|
|
|
+ "content_audience_profile",
|
|
|
+ "interaction_performance",
|
|
|
+ "freshness_available",
|
|
|
+ "douyin_tone",
|
|
|
+ "adaptability"
|
|
|
+ ]);
|
|
|
+ return (
|
|
|
+ <div className="scorecard-row">
|
|
|
+ <span className="scorecard-label">评分维度</span>
|
|
|
+ {dims.map((d, i) => {
|
|
|
+ const key = String(d.key || "");
|
|
|
+ const deprecated = DEPRECATED.has(key);
|
|
|
+ return (
|
|
|
+ <span className={`scorecard-chip${deprecated ? " deprecated" : ""}`} key={key || i}>
|
|
|
+ {key}:{d.score == null ? "—" : compactValue(d.score)}
|
|
|
+ {d.max_score != null ? ` / ${compactValue(d.max_score)}` : ""}
|
|
|
+ {deprecated ? "(弃用)" : ""}
|
|
|
+ </span>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
function rulePackLabel(rulePack: unknown): string {
|
|
|
const value = String(rulePack || "");
|
|
|
const labels: Record<string, string> = {
|
|
|
@@ -835,7 +932,14 @@ function reasonLabel(reason: unknown): string {
|
|
|
missing_source_evidence: "来源证据缺失:无法追溯这条内容来自哪个 query / path",
|
|
|
missing_platform_content_id: "内容身份缺失:缺少平台视频 ID",
|
|
|
missing_score: "分数缺失:无法完成分数阈值判断",
|
|
|
- high_risk_content: "安全风险高:内容命中高风险判断"
|
|
|
+ high_risk_content: "安全风险高:内容命中高风险判断",
|
|
|
+ // V3 Gemini 判定原因码
|
|
|
+ content_not_fit_senior: "Gemini 判定:内容不适合 50+ 人群",
|
|
|
+ content_low_confidence: "Gemini 判定置信度过低(< 0.6)",
|
|
|
+ content_judge_failed: "Gemini 判定技术失败:进入待复看",
|
|
|
+ content_score_reject: "综合分未达阈值:淘汰",
|
|
|
+ age_50_plus_weak: "50+ 适配偏弱",
|
|
|
+ category_or_element_binding_required: "类目 / 要素绑定未命中"
|
|
|
};
|
|
|
return labels[value] || compactValue(reason);
|
|
|
}
|