Преглед изворни кода

feat(web-v3): 判定视角升级(Gemini 判定卡 + decode 清理)

- 判定卡新增 Gemini 判定行(fit_senior_50plus/置信度/相关性/状态/理由),仅 V3 字段存在时渲染
- scorecard 按实录维度渲染,V2 旧 5 维标弃用删除线;reasonLabel 补 V3 原因码
- TimelineSummaryStrip:删 decodeLine 改 Gemini 判定计数+配额截断警示;decode 转历史字段(仅非空显示)
- RunTimelinePage:decode 过滤器→判定事件;新增 Gemini 配额卡(used/cap)
- 实测 V3 run 4e24c1b85637(判定行满字段)+ V2 run 040e6a(空行抑制)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sam Lee пре 1 дан
родитељ
комит
2e2dcf2735
3 измењених фајлова са 205 додато и 15 уклоњено
  1. 64 0
      web/app/globals.css
  2. 115 11
      web/features/runs/RunDashboardPage.tsx
  3. 26 4
      web/features/runs/RunTimelinePage.tsx

+ 64 - 0
web/app/globals.css

@@ -1209,6 +1209,70 @@ a {
   border-radius: 10px;
   font-size: 12.5px;
 }
+.timeline-strip .strip-warn {
+  color: #a82626;
+  font-weight: 600;
+}
+.timeline-strip .strip-muted {
+  color: #9aa4b5;
+}
+
+/* V3 判定卡:Gemini 判定行 + scorecard 行 */
+.gemini-judge-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 10px;
+  margin: 6px 0;
+  padding: 7px 10px;
+  border-radius: 9px;
+  background: #f3f7ff;
+  border: 1px solid #d8e2f5;
+  font-size: 12.5px;
+  color: #2a3a55;
+}
+.gemini-judge-row.quota {
+  background: #fdecec;
+  border-color: #f3c9c9;
+}
+.gemini-judge-tag {
+  font-size: 11px;
+  font-weight: 700;
+  color: #2f5bd0;
+  background: #e3ecff;
+  border-radius: 5px;
+  padding: 1px 7px;
+}
+.gemini-judge-quota {
+  color: #a82626;
+  font-weight: 600;
+}
+.gemini-judge-reason {
+  width: 100%;
+  color: #6b7689;
+  font-size: 11.5px;
+}
+.scorecard-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+  margin-top: 6px;
+  font-size: 12px;
+}
+.scorecard-label {
+  color: #9aa4b5;
+}
+.scorecard-chip {
+  background: #eef0f4;
+  color: #3a4658;
+  border-radius: 6px;
+  padding: 1px 8px;
+}
+.scorecard-chip.deprecated {
+  color: #9aa4b5;
+  text-decoration: line-through;
+}
 .stage-bars {
   display: grid;
   gap: 6px;

+ 115 - 11
web/features/runs/RunDashboardPage.tsx

@@ -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);
 }

+ 26 - 4
web/features/runs/RunTimelinePage.tsx

@@ -28,7 +28,7 @@ const STAGE_ORDER = [
 const SOURCE_FILTERS: Array<{ id: string; label: string }> = [
   { id: "all", label: "全部" },
   { id: "stage", label: "阶段事件" },
-  { id: "decode", label: "decode 事件" },
+  { id: "judge", label: "判定事件" },
   { id: "walk", label: "游走动作" },
   { id: "path", label: "路径记录" },
   { id: "other", label: "其他" }
@@ -37,7 +37,10 @@ const SOURCE_FILTERS: Array<{ id: string; label: string }> = [
 function itemFilterKind(item: TimelineItem): string {
   const eventType = String(item.event_type || "");
   if (eventType.startsWith("stage_")) return "stage";
-  if (eventType.startsWith("decode_")) return "decode";
+  // V3:Gemini 判定相关事件(配额截断、pattern recall);兼容老 run 的 decode_* 也归此类。
+  if (eventType === "gemini_quota_exhausted" || eventType.startsWith("decode_") || item.source === "pattern_recall_evidence.jsonl") {
+    return "judge";
+  }
   if (item.source === "walk_actions.jsonl") return "walk";
   if (item.source === "source_path_records.jsonl") return "path";
   return "other";
@@ -55,6 +58,16 @@ function countsLine(counts: Record<string, number>): string {
   return entries.map(([key, value]) => `${statusLabel(key)} ${value}`).join(" / ");
 }
 
+// Gemini 配额截断:run_events 里的 gemini_quota_exhausted 事件,显示 used/cap。
+function geminiQuotaLabel(items: TimelineItem[]): string {
+  const ev = items.find((it) => it.event_type === "gemini_quota_exhausted");
+  if (!ev) return "未触发";
+  const payload = rawPayload(ev);
+  const used = payload.used ?? "?";
+  const cap = payload.cap ?? "?";
+  return `已截断 ${used}/${cap}`;
+}
+
 export function RunTimelinePage({ runId }: { runId: string }) {
   const [data, setData] = useState<TimelineResponse | null>(null);
   const [error, setError] = useState<string | null>(null);
@@ -104,7 +117,7 @@ export function RunTimelinePage({ runId }: { runId: string }) {
           <div>
             <h1 className="detail-title">运行时间线 · {runId}</h1>
             <p className="muted">
-              M6 可观测事实:阶段耗时、失败、限流与 decode 中间态。仅呈现事实,不做“卡住”判断。
+              可观测事实:阶段耗时、失败、限流与 Gemini 判定 / 配额。仅呈现事实,不做“卡住”判断。
             </p>
           </div>
           <Link className="muted" href={`/runs/${encodeURIComponent(runId)}`}>
@@ -126,7 +139,16 @@ export function RunTimelinePage({ runId }: { runId: string }) {
                 />
                 <MetricCard label="query 失败" value={summary.query_failure_count} />
                 <MetricCard label="平台限流" value={summary.platform_rate_limited_count} />
-                <MetricCard label="decode 状态" value={countsLine(summary.decode_status_counts ?? {})} />
+                <MetricCard
+                  label="Gemini 配额"
+                  value={geminiQuotaLabel(data?.items || [])}
+                />
+                {summary.decode_status_counts && Object.keys(summary.decode_status_counts).length ? (
+                  <MetricCard
+                    label="decode 状态(历史)"
+                    value={countsLine(summary.decode_status_counts)}
+                  />
+                ) : null}
                 <MetricCard label="错误分布" value={countsLine(summary.error_counts)} />
                 <MetricCard label="游走状态" value={countsLine(summary.walk_status_counts)} />
               </div>