Sfoglia il codice sorgente

Merge branch 'master' into feature/zhangbo

zhang 2 settimane fa
parent
commit
0ea4094030

+ 1 - 1
api/app.py

@@ -53,7 +53,7 @@ def demand_belong_category() -> dict:
 
 @app.get("/api/demand-belong-category/{belong_id}/videos")
 def demand_belong_videos(belong_id: int) -> dict:
-    """Return videos linked to a demand_belong_category row (vid + title + final_topic_json)."""
+    """Return videos linked to a demand_belong_category row (vid + title + points JSON)."""
     result = list_videos_for_demand_belong(belong_id)
     if result is None:
         raise HTTPException(status_code=404, detail="demand_belong_category not found")

+ 6 - 2
api/services/demand_videos.py

@@ -29,7 +29,7 @@ def list_videos_for_demand_belong(belong_id: int) -> dict[str, Any] | None:
     """
     按 demand_belong_category.id 返回关联视频详情。
 
-    顺序与 video_list 一致;详情表缺失的 vid 仍返回,title/final_topic_json 为空。
+    顺序与 video_list 一致;详情表缺失的 vid 仍返回,title/三点 JSON 为空。
     """
     with get_session() as session:
         belong = DemandBelongCategoryRepository(session).get_by_id(belong_id)
@@ -46,7 +46,11 @@ def list_videos_for_demand_belong(belong_id: int) -> dict[str, Any] | None:
                 {
                     "vid": vid,
                     "title": row.title if row else None,
-                    "final_topic_json": row.final_topic_json if row else None,
+                    "inspiration_points_json": (
+                        row.inspiration_points_json if row else None
+                    ),
+                    "purpose_points_json": row.purpose_points_json if row else None,
+                    "key_points_json": row.key_points_json if row else None,
                 }
             )
 

+ 34 - 0
jobs/backfill_multi_demand_video_points.py

@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""回填 multi_demand_video_detail 的灵感点/目的点/关键点
+(decode_result.灵感点 / 目的点 / 关键点,每项保留 点/点描述)。
+
+用法:
+    python jobs/backfill_multi_demand_video_points.py
+    python jobs/backfill_multi_demand_video_points.py 100   # 每批 100
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+
+from supply_infra.scheduler.jobs.sync_multi_demand_videos import (
+    VIDEO_SYNC_BATCH_SIZE,
+    backfill_video_points,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+
+
+def main(batch_arg: str | None = None) -> dict:
+    batch_size = int(batch_arg) if batch_arg else VIDEO_SYNC_BATCH_SIZE
+    result = backfill_video_points(batch_size=batch_size)
+    print(result)
+    return result
+
+
+if __name__ == "__main__":
+    main(sys.argv[1] if len(sys.argv) > 1 else None)

+ 15 - 0
supply_infra/db/models/multi_demand_video_detail.py

@@ -22,6 +22,21 @@ class MultiDemandVideoDetail(Base):
     final_topic_json: Mapped[str] = mapped_column(
         Text, nullable=False, comment="最终选题JSON"
     )
+    inspiration_points_json: Mapped[str | None] = mapped_column(
+        Text,
+        nullable=True,
+        comment="灵感点列表JSON(decode_result.灵感点,每项保留 点/点描述)",
+    )
+    purpose_points_json: Mapped[str | None] = mapped_column(
+        Text,
+        nullable=True,
+        comment="目的点列表JSON(decode_result.目的点,每项保留 点/点描述)",
+    )
+    key_points_json: Mapped[str | None] = mapped_column(
+        Text,
+        nullable=True,
+        comment="关键点列表JSON(decode_result.关键点,每项保留 点/点描述)",
+    )
     create_time: Mapped[datetime] = mapped_column(
         nullable=False,
         server_default=func.now(),

+ 27 - 0
supply_infra/db/repositories/multi_demand_video_detail_repo.py

@@ -55,6 +55,15 @@ class MultiDemandVideoDetailRepository(BaseRepository[MultiDemandVideoDetail]):
         )
         return [str(v) for v in self.session.scalars(stmt).all() if v]
 
+    def list_vids_missing_points(self) -> list[str]:
+        """返回灵感点/目的点/关键点任一字段为空的全部 vid。"""
+        stmt = select(MultiDemandVideoDetail.vid).where(
+            MultiDemandVideoDetail.inspiration_points_json.is_(None)
+            | MultiDemandVideoDetail.purpose_points_json.is_(None)
+            | MultiDemandVideoDetail.key_points_json.is_(None)
+        )
+        return [str(v) for v in self.session.scalars(stmt).all() if v]
+
     def bulk_insert_ignore(self, rows: list[dict]) -> int:
         """批量插入,MySQL 自动忽略 vid 重复行。"""
         if not rows:
@@ -83,3 +92,21 @@ class MultiDemandVideoDetailRepository(BaseRepository[MultiDemandVideoDetail]):
             result = self.session.execute(stmt)
             updated += result.rowcount or 0
         return updated
+
+    def update_points(self, points_by_vid: dict[str, dict[str, str | None]]) -> int:
+        """按 vid 批量更新灵感点/目的点/关键点(values 为落库字段名 → JSON 文本)。"""
+        if not points_by_vid:
+            return 0
+
+        updated = 0
+        for vid, values in points_by_vid.items():
+            if not values:
+                continue
+            stmt = (
+                update(MultiDemandVideoDetail)
+                .where(MultiDemandVideoDetail.vid == vid)
+                .values(**values)
+            )
+            result = self.session.execute(stmt)
+            updated += result.rowcount or 0
+        return updated

+ 122 - 0
supply_infra/scheduler/jobs/sync_multi_demand_videos.py

@@ -21,6 +21,14 @@ logger = logging.getLogger(__name__)
 _FINAL_TOPIC_KEY = "最终选题"
 _TARGET_POST_KEY = "target_post"
 _TITLE_KEY = "title"
+_POINT_KEY = "点"
+_POINT_DESC_KEY = "点描述"
+# decode_result 中的点位 key → 落库字段名
+_POINT_FIELD_MAP = {
+    "灵感点": "inspiration_points_json",
+    "目的点": "purpose_points_json",
+    "关键点": "key_points_json",
+}
 VIDEO_SYNC_BATCH_SIZE = 100
 
 
@@ -75,6 +83,35 @@ def _extract_final_topic_json(payload: dict[str, Any]) -> str | None:
     return json.dumps(final_topic, ensure_ascii=False)
 
 
+def _extract_points(payload: dict[str, Any], key: str) -> str | None:
+    """从 decode_result[key](灵感点/目的点/关键点)取每项的 点/点描述,重组为 JSON 文本。"""
+    items = payload.get(key)
+    if not isinstance(items, list):
+        return None
+
+    points: list[dict[str, Any]] = []
+    for item in items:
+        if not isinstance(item, dict):
+            continue
+        point = item.get(_POINT_KEY)
+        point_desc = item.get(_POINT_DESC_KEY)
+        if point is None and point_desc is None:
+            continue
+        points.append({_POINT_KEY: point, _POINT_DESC_KEY: point_desc})
+
+    if not points:
+        return None
+    return json.dumps(points, ensure_ascii=False)
+
+
+def _extract_all_points(payload: dict[str, Any]) -> dict[str, str | None]:
+    """从 decode_result 提取灵感点/目的点/关键点三个字段,返回 落库字段名 → JSON 文本。"""
+    return {
+        field: _extract_points(payload, key)
+        for key, field in _POINT_FIELD_MAP.items()
+    }
+
+
 def _extract_title(payload: dict[str, Any]) -> str | None:
     """从 decode_result.target_post.title 取标题。"""
     target_post = payload.get(_TARGET_POST_KEY)
@@ -130,6 +167,7 @@ def _sync_one_batch(
                 "vid": vid,
                 "title": _extract_title(payload),
                 "final_topic_json": topic_json,
+                **_extract_all_points(payload),
             }
         )
 
@@ -344,3 +382,87 @@ def backfill_video_titles(
     }
     logger.info("Backfill video titles completed: %s", result)
     return result
+
+
+def backfill_video_points(
+    batch_size: int = VIDEO_SYNC_BATCH_SIZE,
+) -> dict[str, Any]:
+    """为灵感点/目的点/关键点字段缺失的已有行,从 ODPS 昨天分区回填。"""
+    decode_dt = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+    chunk = max(1, int(batch_size))
+    logger.info("Backfill video points: decode_dt=%s batch_size=%d", decode_dt, chunk)
+
+    with get_session() as session:
+        pending = MultiDemandVideoDetailRepository(session).list_vids_missing_points()
+
+    if not pending:
+        result = {
+            "decode_dt": decode_dt,
+            "pending": 0,
+            "batches": 0,
+            "updated": 0,
+            "missing_in_odps": 0,
+            "skipped_no_decode": 0,
+        }
+        logger.info("No vids missing points: %s", result)
+        return result
+
+    odps = get_odps_client()
+    batches = [pending[i : i + chunk] for i in range(0, len(pending), chunk)]
+    total_updated = 0
+    total_missing = 0
+    total_skipped = 0
+
+    for idx, batch_vids in enumerate(batches, start=1):
+        logger.info(
+            "Points backfill batch %d/%d: %d vids",
+            idx,
+            len(batches),
+            len(batch_vids),
+        )
+        odps_rows = odps.fetch_topic_decode_results(
+            decode_dt, batch_vids, batch_size=len(batch_vids)
+        )
+        points_by_vid: dict[str, dict[str, str | None]] = {}
+        for row in odps_rows:
+            raw_vid = row.get("vid")
+            if raw_vid is None:
+                continue
+            vid = str(raw_vid).strip()
+            if not vid or vid in points_by_vid:
+                continue
+            payload = _parse_decode_result(row.get("decode_result"))
+            if payload is None:
+                total_skipped += 1
+                continue
+            points_by_vid[vid] = _extract_all_points(payload)
+
+        with get_session() as session:
+            updated = MultiDemandVideoDetailRepository(session).update_points(
+                points_by_vid
+            )
+        total_updated += updated
+
+        odps_vids = {
+            str(r.get("vid")).strip()
+            for r in odps_rows
+            if r.get("vid") is not None and str(r.get("vid")).strip()
+        }
+        total_missing += len(set(batch_vids) - odps_vids)
+        logger.info(
+            "Points backfill batch %d/%d done: updated=%d",
+            idx,
+            len(batches),
+            updated,
+        )
+
+    result = {
+        "decode_dt": decode_dt,
+        "pending": len(pending),
+        "batches": len(batches),
+        "updated": total_updated,
+        "missing_in_odps": total_missing,
+        "skipped_no_decode": total_skipped,
+    }
+    logger.info("Backfill video points completed: %s", result)
+    return result

+ 210 - 16
web/src/components/DemandPathPanel.vue

@@ -31,14 +31,62 @@ const selectedVideo = computed(
   () => videos.value.find((v) => v.vid === selectedVid.value) ?? null,
 )
 
-const topicDisplay = computed(() => {
-  const raw = selectedVideo.value?.final_topic_json
-  if (!raw) return null
+interface TopicPoint {
+  title: string
+  description: string
+}
+
+interface TopicSection {
+  key: string
+  label: string
+  accent: string
+  items: TopicPoint[]
+}
+
+function parsePointsJson(raw: string | null | undefined): TopicPoint[] {
+  if (!raw) return []
   try {
-    return JSON.stringify(JSON.parse(raw), null, 2)
+    const parsed = JSON.parse(raw)
+    const list = Array.isArray(parsed) ? parsed : [parsed]
+    return list
+      .filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
+      .map((item) => ({
+        title: String(item['点'] ?? '').trim(),
+        description: String(item['点描述'] ?? '').trim(),
+      }))
+      .filter((item) => item.title || item.description)
   } catch {
-    return raw
+    return []
   }
+}
+
+const topicSections = computed((): TopicSection[] | null => {
+  const video = selectedVideo.value
+  if (!video) return null
+
+  const sections: TopicSection[] = [
+    {
+      key: 'inspiration',
+      label: '灵感点',
+      accent: 'inspiration',
+      items: parsePointsJson(video.inspiration_points_json),
+    },
+    {
+      key: 'purpose',
+      label: '目的点',
+      accent: 'purpose',
+      items: parsePointsJson(video.purpose_points_json),
+    },
+    {
+      key: 'key',
+      label: '关键点',
+      accent: 'key',
+      items: parsePointsJson(video.key_points_json),
+    },
+  ]
+
+  if (sections.every((s) => s.items.length === 0)) return null
+  return sections
 })
 
 watch(
@@ -180,9 +228,31 @@ function selectVideo(video: DemandVideoItem) {
     <section class="col col-topic">
       <h3 class="col-title">选题结果</h3>
       <template v-if="selectedVideo">
-        <div v-if="topicDisplay" class="topic-card">
+        <div v-if="topicSections" class="topic-card">
           <div class="topic-meta">Video {{ selectedVideo.vid }}</div>
-          <pre class="topic-json">{{ topicDisplay }}</pre>
+          <div class="topic-body">
+            <section
+              v-for="section in topicSections"
+              :key="section.key"
+              class="topic-section"
+              :class="`accent-${section.accent}`"
+            >
+              <div class="section-head">
+                <span class="section-label">{{ section.label }}</span>
+                <span class="section-count">{{ section.items.length }}</span>
+              </div>
+              <div v-if="section.items.length" class="point-list">
+                <article v-for="(item, idx) in section.items" :key="idx" class="point-item">
+                  <div class="point-title-row">
+                    <span class="point-index">{{ idx + 1 }}</span>
+                    <h4 class="point-title">{{ item.title || '未命名' }}</h4>
+                  </div>
+                  <p v-if="item.description" class="point-desc">{{ item.description }}</p>
+                </article>
+              </div>
+              <div v-else class="section-empty">暂无</div>
+            </section>
+          </div>
         </div>
         <div v-else class="empty">暂无选题结果</div>
       </template>
@@ -210,7 +280,7 @@ function selectVideo(video: DemandVideoItem) {
 }
 
 .col-topic {
-  width: 300px;
+  width: 380px;
 }
 
 .col-head {
@@ -413,7 +483,7 @@ function selectVideo(video: DemandVideoItem) {
   border-radius: 12px;
   background: linear-gradient(180deg, #faf5ff 0%, #fff 45%);
   overflow: hidden;
-  max-height: 420px;
+  max-height: 520px;
   display: flex;
   flex-direction: column;
 }
@@ -425,20 +495,144 @@ function selectVideo(video: DemandVideoItem) {
   font-weight: 600;
   color: #7e22ce;
   font-variant-numeric: tabular-nums;
+  flex-shrink: 0;
 }
 
-.topic-json {
-  margin: 0;
-  padding: 10px 12px;
+.topic-body {
+  padding: 10px 12px 12px;
   overflow: auto;
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-  font-size: 11px;
-  line-height: 1.45;
-  color: #334155;
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.topic-section {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.section-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.section-label {
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: 0.02em;
+}
+
+.section-count {
+  min-width: 18px;
+  height: 18px;
+  padding: 0 5px;
+  border-radius: 999px;
+  font-size: 10px;
+  font-weight: 700;
+  line-height: 18px;
+  text-align: center;
+  font-variant-numeric: tabular-nums;
+}
+
+.accent-inspiration .section-label {
+  color: #c2410c;
+}
+.accent-inspiration .section-count {
+  color: #c2410c;
+  background: #ffedd5;
+}
+.accent-inspiration .point-index {
+  background: #ffedd5;
+  color: #c2410c;
+}
+
+.accent-purpose .section-label {
+  color: #1d4ed8;
+}
+.accent-purpose .section-count {
+  color: #1d4ed8;
+  background: #dbeafe;
+}
+.accent-purpose .point-index {
+  background: #dbeafe;
+  color: #1d4ed8;
+}
+
+.accent-key .section-label {
+  color: #7e22ce;
+}
+.accent-key .section-count {
+  color: #7e22ce;
+  background: #f3e8ff;
+}
+.accent-key .point-index {
+  background: #f3e8ff;
+  color: #7e22ce;
+}
+
+.point-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.point-item {
+  padding: 10px 11px;
+  border-radius: 10px;
+  background: #fff;
+  border: 1px solid #e2e8f0;
+  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
+}
+
+.point-title-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+}
+
+.point-index {
+  flex-shrink: 0;
+  width: 18px;
+  height: 18px;
+  margin-top: 1px;
+  border-radius: 5px;
+  font-size: 10px;
+  font-weight: 700;
+  line-height: 18px;
+  text-align: center;
+  font-variant-numeric: tabular-nums;
+}
+
+.point-title {
+  margin: 0;
+  font-size: 13px;
+  font-weight: 700;
+  line-height: 1.35;
+  color: #0f172a;
+  word-break: break-word;
+}
+
+.point-desc {
+  margin: 6px 0 0;
+  padding-left: 26px;
+  font-size: 12px;
+  line-height: 1.55;
+  color: #475569;
   white-space: pre-wrap;
   word-break: break-word;
 }
 
+.section-empty {
+  padding: 8px 10px;
+  font-size: 12px;
+  color: #94a3b8;
+  border: 1px dashed #e2e8f0;
+  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.6);
+}
+
 .empty {
   padding: 20px 10px;
   text-align: center;

+ 3 - 1
web/src/types/demand.ts

@@ -12,7 +12,9 @@ export interface DemandBelongResponse {
 export interface DemandVideoItem {
   vid: string
   title: string | null
-  final_topic_json: string | null
+  inspiration_points_json: string | null
+  purpose_points_json: string | null
+  key_points_json: string | null
 }
 
 export interface DemandVideosResponse {