Przeglądaj źródła

场景需求-开发中

luojunhui 14 godzin temu
rodzic
commit
9ca4483328

+ 2 - 1
app/domains/recommend/demand_recommend/__init__.py

@@ -1,5 +1,5 @@
 from ._const import DemandRecommendConst, MatchMethod, ConfigCode, DemandSource
-from ._utils import DemandRecord, MatchStrategy, MatchResult, DemandStrategyParser, parse_recall_items
+from ._utils import DemandRecord, MatchStrategy, MatchResult, DemandStrategyParser, parse_recall_items, parse_scored_items
 from ._mapper import DemandRecommendMapper
 from .match_engine import DemandVideoMatchEngine
 from .entrance import DemandRecommendTask
@@ -17,4 +17,5 @@ __all__ = [
     "DemandVideoMatchEngine",
     "DemandRecommendTask",
     "parse_recall_items",
+    "parse_scored_items",
 ]

+ 3 - 0
app/domains/recommend/demand_recommend/_const.py

@@ -58,7 +58,10 @@ class DemandRecommendConst:
         MATCH_TOP_N_VIDEO = "/videoSearch/matchTopNVideo"
         DECONSTRUCT = "/videoSearch/deconstruct"
         GET_DECONSTRUCT_RESULT = "/videoSearch/getDeconstructResult"
+        GET_DECONSTRUCT_RESULT_MINI = "/videoSearch/getDeconstructResultMini"
         GET_ALL_CONFIG_CODES = "/videoSearch/getAllConfigCodes"
+        RECALL_WITH_SCORE = "/videoSearch/recallWithScore"
+        QUERY_DEMAND_MATCH_RESULT = "/videoSearch/queryDemandMatchResult"
 
     # ── 匹配结果表 ──
     MATCH_RESULT_TABLE = "demand_video_match"

+ 50 - 0
app/domains/recommend/demand_recommend/_utils.py

@@ -103,6 +103,11 @@ class MatchResult:
     rank_position: int = 0
     video_title: str = ""
     video_detail: Optional[Dict[str, Any]] = None
+    # recallWithScore 专用字段,非 scoring 模式时为 0
+    sim: float = 0.0
+    sim_norm: float = 0.0
+    rov: float = 0.0
+    rov_norm: float = 0.0
 
 
 # ──────────────────────────────────────────────
@@ -239,6 +244,51 @@ def parse_recall_items(
             rank_position=rank,
             video_title=str(item.get("title", "")),
             video_detail=item.get("videoDetail"),
+            sim=float(item.get("sim", 0)),
+            sim_norm=float(item.get("simNorm", 0)),
+            rov=float(item.get("rov", 0)),
+            rov_norm=float(item.get("rovNorm", 0)),
+        ))
+    return results
+
+
+def parse_scored_items(
+    api_response: Dict[str, Any],
+    strategy: MatchStrategy,
+    config_code: str,
+) -> List[MatchResult]:
+    """解析 recallWithScore 返回的 scored items 为 MatchResult 列表"""
+    if not api_response or api_response.get("code") != 0:
+        return []
+
+    data = api_response.get("data")
+    if not data:
+        return []
+
+    items = data.get("items", [])
+    results: List[MatchResult] = []
+    for rank, item in enumerate(items, start=1):
+        vid = item.get("videoId", 0)
+        if not vid:
+            continue
+        detail = item.get("videoDetail") or {}
+        # 优先从 videoDetail 取真实标题,取不到用 text(向量化选题)
+        raw_title = detail.get("title") or detail.get("选题") or str(item.get("text", ""))
+        results.append(MatchResult(
+            dt=strategy.dt,
+            demand_id=strategy.demand_id,
+            match_experiment_id=strategy.experiment_id,
+            match_method=MatchMethod.TEXT,
+            config_code=item.get("configCode", config_code),
+            video_id=int(vid),
+            score=float(item.get("score", 0) or item.get("sim", 0)),
+            rank_position=rank,
+            video_title=raw_title,
+            video_detail=detail,
+            sim=float(item.get("sim", 0)),
+            sim_norm=float(item.get("simNorm", 0)),
+            rov=float(item.get("rov", 0)),
+            rov_norm=float(item.get("rovNorm", 0)),
         ))
     return results
 

+ 1375 - 0
app/domains/recommend/demand_recommend/api-documentation.md

@@ -0,0 +1,1375 @@
+# Video Search & Vector Recall API 接口文档
+
+> 项目: video-vector-server  
+> 文档版本: 2026-05-09  
+> 适用 Controller: `VideoSearchController`、`VectorRecallTestController`、`MaterialController`、`FileController`、`HealthController`、`XxlJobController`
+
+---
+
+## 通用约定
+
+### 基础路径
+
+| Controller | 前缀 |
+|---|---|
+| VideoSearchController | `/videoSearch` |
+| VectorRecallTestController | `/recallTest` |
+| MaterialController | `/material` |
+| FileController | `/file` |
+| HealthController | `/` |
+| XxlJobController | `/job` |
+
+### 统一响应格式 `CommonResponse<T>`
+
+所有接口均返回以下 JSON 结构:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": <T>
+}
+```
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `code` | `int` | 业务状态码。`0` 表示成功 |
+| `msg` | `String` | 状态消息。成功时为 `"success"` |
+| `data` | `T` (泛型) | 业务数据,具体结构见各接口定义。失败时可能为 `null` |
+
+### 全局行为
+
+- **CORS**: 由全局拦截器 `CrosDomainAllowInterceptor` 统一处理,前端无需额外配置。`FileController` 额外加了 `@CrossOrigin(origins = "*")`
+- **鉴权**: MVP 阶段不校验鉴权,所有接口均可直接调用
+
+---
+
+## 一、VideoSearchController (`/videoSearch`)
+
+视频解构与相似视频匹配。
+
+### 1.1 触发内容解构
+
+```
+POST /videoSearch/deconstruct
+```
+
+**用途**: 对指定内容(长文/图文/视频)发起 AI 解构任务,提取选题、灵感点、关键点、目的点等结构化信息。
+
+**Request Body** (`application/json`):
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `bizType` | `Integer` | 否 | 业务类型。`0` = 投流 |
+| `contentType` | `Integer` | 否 | 内容类型: `1` = 长文, `2` = 图文, `3` = 视频 |
+| `channelContentId` | `String` | 否 | 业务内容ID(帖子ID/视频ID) |
+| `title` | `String` | 否 | 标题 |
+| `bodyText` | `String` | 否 | 正文内容 |
+| `videoUrl` | `String` | 否 | 视频地址 |
+| `imageList` | `List<String>` | 否 | 图片URL列表 |
+| `channelAccountId` | `String` | 否 | 作者ID |
+| `channelAccountName` | `String` | 否 | 作者名称 |
+
+**Request 示例**:
+
+```json
+{
+  "bizType": 0,
+  "contentType": 3,
+  "channelContentId": "abc123",
+  "title": "产品种草视频",
+  "bodyText": "这是一款非常好用的护肤品...",
+  "videoUrl": "https://example.com/video.mp4",
+  "imageList": ["https://example.com/img1.jpg"],
+  "channelAccountId": "account_001",
+  "channelAccountName": "美妆达人小A"
+}
+```
+
+**Response**:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": "<taskId>"
+}
+```
+
+`data` 为 `String` 类型,返回解构任务的唯一标识 `taskId`,可用于后续调用 `getDeconstructResult` 查询结果。
+
+---
+
+### 1.2 查询解构结果
+
+```
+POST /videoSearch/getDeconstructResult
+```
+
+**用途**: 根据任务ID或业务内容ID查询解构任务的处理结果。
+
+**Request Body** (`application/json`):
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `taskId` | `String` | 否 | 解构任务ID(由 `deconstruct` 接口返回) |
+| `bizType` | `Integer` | 否 | 业务类型: `0` = 选题, `1` = 创作, `2` = 制作 |
+| `contentType` | `Integer` | 否 | 内容类型: `1` = 长文, `2` = 图文, `3` = 视频 |
+| `channelContentId` | `String` | 否 | 业务内容ID(帖子ID/视频ID) |
+| `forceRefresh` | `Boolean` | 否 | 是否强制从远程API重新拉取,不走Redis缓存 |
+
+> **查询逻辑**: 至少提供 `taskId` 或 `channelContentId` 之一。若提供 `taskId` 则按任务ID查询;若提供 `channelContentId` 则按内容ID查询最新结果。
+
+**Request 示例**:
+
+```json
+{
+  "taskId": "task_20260507_001",
+  "bizType": 0,
+  "contentType": 3,
+  "channelContentId": "abc123",
+  "forceRefresh": false
+}
+```
+
+**Response**: `data` 为 `JSONObject`,结构为解构引擎返回的原始结果JSON,包含但不限于:
+
+- 选题(topic)
+- 灵感点(inspiration points)
+- 关键点(key points)
+- 目的点(purpose points)
+- 各点的实质词汇及贡献度分数(0~1)
+
+---
+
+### 1.3 按内容ID获取解构结果
+
+```
+GET /videoSearch/getDeconstructResultByChannelContentId
+```
+
+**用途**: 通过业务内容ID直接获取解构结果(简化版,仅需 channelContentId)。
+
+**Query Parameters**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `channelContentId` | `String` | 是 | 业务内容ID(帖子ID/视频ID) |
+
+**示例请求**:
+
+```
+GET /videoSearch/getDeconstructResultByChannelContentId?channelContentId=abc123
+```
+
+**Response**: `data` 为 `JSONObject`,与接口 1.2 返回结构一致。
+
+---
+
+### 1.4 查询解构结果(精简版)
+
+```
+POST /videoSearch/getDeconstructResultMini
+```
+
+**用途**: 查询解构结果的精简版,参数与 `getDeconstructResult` 一致,返回裁剪后的解构结果。
+
+**Request Body** (`application/json`):
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `taskId` | `String` | 否 | 解构任务ID |
+| `bizType` | `Integer` | 否 | 业务类型: `0` = 选题, `1` = 创作, `2` = 制作 |
+| `contentType` | `Integer` | 否 | 内容类型: `1` = 长文, `2` = 图文, `3` = 视频 |
+| `channelContentId` | `String` | 否 | 业务内容ID(帖子ID/视频ID) |
+| `forceRefresh` | `Boolean` | 否 | 是否强制从远程API重新拉取 |
+
+**Request 示例**:
+
+```json
+{
+  "taskId": "task_20260507_001",
+  "bizType": 0,
+  "contentType": 3,
+  "channelContentId": "abc123",
+  "forceRefresh": false
+}
+```
+
+**Response**: `data` 为 `JSONObject`,返回精简后的解构结果JSON。
+
+---
+
+### 1.5 相似视频匹配(Top-N 召回)
+
+```
+POST /videoSearch/matchTopNVideo
+```
+
+**用途**: 基于内容解构结果,在向量库中检索最相似的 Top-N 个视频。
+
+**Request Body** (`application/json`):
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `channelContentId` | `String` | 否 | 参考内容的业务ID,用于自动获取该内容的解构向量 |
+| `configCode` | `String` | 否 | 向量配置编码,指定搜索维度。不传使用默认配置 |
+| `queryText` | `String` | 否 | 查询文本,将被向量化后检索 |
+| `queryVector` | `List<Float>` | 否 | 直接传入查询向量,优先级高于 `queryText` |
+| `topN` | `Integer` | 否 | 返回结果数量,默认 `10` |
+
+> **检索逻辑**: `queryVector` > `queryText` > `channelContentId`。即若传入 `queryVector` 直接用于检索;否则若有 `queryText` 则将其向量化;否则使用 `channelContentId` 的解构向量进行检索。
+
+**Request 示例**:
+
+```json
+{
+  "channelContentId": "abc123",
+  "configCode": "VIDEO_TOPIC",
+  "topN": 10
+}
+```
+
+按文本查询:
+
+```json
+{
+  "configCode": "VIDEO_INSPIRATION",
+  "queryText": "如何做美妆教程",
+  "topN": 20
+}
+```
+
+**Response**: `data` 为 `List<VideoMatchResult>`:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": [
+    {
+      "configCode": "VIDEO_TOPIC",
+      "videoId": 10001,
+      "score": 0.9523,
+      "text": "美妆护肤教程 春季妆容",
+      "videoDetail": {
+        "topic": "美妆护肤",
+        "灵感点": "产品成分解读",
+        "灵感点-实质": [
+          { "word": "成分", "score": 0.92 },
+          { "word": "安全", "score": 0.88 }
+        ],
+        "关键点": "使用时需注意过敏",
+        "关键点-实质": [
+          { "word": "过敏", "score": 0.85 }
+        ],
+        "目的点": "种草转化",
+        "目的点-实质": [
+          { "word": "购买", "score": 0.91 }
+        ]
+      }
+    }
+  ]
+}
+```
+
+**VideoMatchResult 字段说明**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `configCode` | `String` | 命中时使用的向量配置编码 |
+| `videoId` | `Long` | 匹配到的视频ID |
+| `score` | `Double` | 余弦相似度分值(0~1,越大越相似) |
+| `text` | `String` | 向量化原文 |
+| `videoDetail` | `Map<String, Object>` | 视频解构详情,来源 Redis `recall:vid_decode:{vid}`。包含 topic、灵感点、关键点、目的点及其"实质"分词信息 |
+
+---
+
+### 1.6 召回视频并按综合评分排序
+
+```
+POST /videoSearch/recallWithScore
+```
+
+**用途**: 召回视频后按综合评分排序返回。综合分 = `alpha * sim_norm + (1-alpha) * rov_norm`,同时考虑相似度和效率分。
+
+**Request Body** (`application/json`):
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `channelContentId` | `String` | 否 | 业务内容ID(帖子ID/视频ID) |
+| `configCode` | `String` | 否 | 向量配置编码 |
+| `queryText` | `String` | 否 | 查询文本,将被向量化后检索 |
+| `queryVector` | `List<Float>` | 否 | 直接传入查询向量,优先级高于 `queryText` |
+| `topN` | `Integer` | 否 | 返回 Top-N 结果数量,默认 `10` |
+| `alpha` | `Double` | 否 | 相关性权重,取值 0~1,默认 `0.6`。越大越看重相关性(sim) |
+| `rovP95` | `Double` | 否 | ROV 全局历史 P95,默认 `0.05` |
+| `rovP5` | `Double` | 否 | ROV 全局历史 P5,默认 `0.005` |
+| `simMin` | `Double` | 否 | 相似度下界(粗筛阈值),默认 `0.7` |
+
+**Request 示例**:
+
+```json
+{
+  "configCode": "VIDEO_TOPIC",
+  "queryText": "美妆护肤教程",
+  "topN": 20,
+  "alpha": 0.6,
+  "simMin": 0.7
+}
+```
+
+**Response**: `data` 为 `RecallVideoScoreVO`:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "items": [
+      {
+        "videoId": 10001,
+        "configCode": "VIDEO_TOPIC",
+        "sim": 0.9523,
+        "simNorm": 0.87,
+        "rov": 0.032,
+        "rovNorm": 0.65,
+        "score": 0.782,
+        "text": "美妆护肤教程 春季妆容",
+        "videoDetail": { }
+      }
+    ],
+    "total": 1,
+    "scoreParams": {
+      "alpha": 0.6,
+      "rovP95": 0.05,
+      "rovP5": 0.005,
+      "simMin": 0.7,
+      "simMax": 0.98
+    }
+  }
+}
+```
+
+**RecallVideoScoreVO 字段说明**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `items` | `List<ScoredVideoItem>` | 按综合分降序排列的结果列表 |
+| `total` | `int` | 总条数 |
+| `scoreParams` | `ScoreParams` | 本次使用的评分参数 |
+
+**ScoredVideoItem 子结构**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `videoId` | `Long` | 匹配到的视频ID |
+| `configCode` | `String` | 命中的配置编码 |
+| `sim` | `Double` | 原始相似度分数 |
+| `simNorm` | `Double` | 归一化后的相似度 |
+| `rov` | `Double` | 效率分(来自视频维度表) |
+| `rovNorm` | `Double` | 归一化后的效率分 |
+| `score` | `Double` | 综合得分 = alpha * sim_norm + (1-alpha) * rov_norm |
+| `text` | `String` | 向量化原文 |
+| `videoDetail` | `Map<String, Object>` | 视频基础信息及解构子对象 |
+
+**ScoreParams 子结构**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `alpha` | `Double` | 相关性权重 |
+| `rovP95` | `Double` | ROV P95 |
+| `rovP5` | `Double` | ROV P5 |
+| `simMin` | `Double` | 相似度下界 |
+| `simMax` | `Double` | 相似度上界 |
+
+---
+
+### 1.7 查询渠道需求匹配结果
+
+```
+POST /videoSearch/queryDemandMatchResult
+```
+
+**用途**: 查询渠道需求匹配结果,按日期、渠道、人群、维度查询匹配到的视频ID及分数。
+
+**Request Body** (`application/json`):
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `dt` | `String` | 是 | 数据日期,格式 `yyyyMMdd` |
+| `channelName` | `String` | 是 | 渠道类名称 |
+| `crowdSegment` | `String` | 否 | 人群细分 |
+| `dimension` | `String` | 否 | 维度(如"传播的分发") |
+| `pointType` | `String` | 否 | 点类型(关键点/灵感点/目的点) |
+| `standardElement` | `String` | 否 | 标准化元素(搜索词) |
+
+**Request 示例**:
+
+```json
+{
+  "dt": "20260509",
+  "channelName": "美妆类",
+  "crowdSegment": "年轻女性",
+  "dimension": "传播的分发",
+  "pointType": "灵感点",
+  "standardElement": "成分安全"
+}
+```
+
+**Response**: `data` 为 `List<ChannelDemandMatchVO>`:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": [
+    {
+      "dt": "20260509",
+      "channelName": "美妆类",
+      "crowdSegment": "年轻女性",
+      "dimension": "传播的分发",
+      "pointType": "灵感点",
+      "standardElement": "成分安全",
+      "categoryName": "护肤品",
+      "visitUv": 12345,
+      "totalRov": 0.052,
+      "matchedVideos": [
+        {
+          "videoId": 10001,
+          "configCode": "VIDEO_INSPIRATION",
+          "score": 0.852,
+          "sim": 0.92,
+          "rov": 0.035,
+          "text": "护肤品成分安全解析"
+        }
+      ]
+    }
+  ]
+}
+```
+
+**ChannelDemandMatchVO 字段说明**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `dt` | `String` | 数据日期 |
+| `channelName` | `String` | 渠道类名称 |
+| `crowdSegment` | `String` | 人群细分 |
+| `dimension` | `String` | 维度 |
+| `pointType` | `String` | 点类型 |
+| `standardElement` | `String` | 标准化元素(搜索词) |
+| `categoryName` | `String` | 分类名称 |
+| `visitUv` | `Long` | 访问 UV |
+| `totalRov` | `Double` | 总 ROV |
+| `matchedVideos` | `List<MatchedVideo>` | 匹配到的视频列表 |
+
+**MatchedVideo 子结构**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `videoId` | `Long` | 匹配视频ID |
+| `configCode` | `String` | 匹配使用的向量配置编码 |
+| `score` | `Double` | 综合评分 |
+| `sim` | `Double` | 相似度分数 |
+| `rov` | `Double` | 匹配视频 ROV |
+| `text` | `String` | 匹配命中的向量原文 |
+
+---
+
+### 1.8 获取所有配置编码
+
+```
+GET /videoSearch/getAllConfigCodes
+```
+
+**用途**: 查询系统支持的所有向量配置编码及对应描述,供前端下拉选择。
+
+**请求参数**: 无。
+
+**示例请求**:
+
+```
+GET /videoSearch/getAllConfigCodes
+```
+
+**Response**: `data` 为 `Map<String, String>`,key 为 configCode,value 为中文描述:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "VIDEO_TOPIC": "选题维度",
+    "VIDEO_INSPIRATION": "灵感点维度",
+    "VIDEO_KEYPOINT": "关键点维度",
+    "VIDEO_PURPOSE": "目的点维度"
+  }
+}
+```
+
+---
+
+## 二、VectorRecallTestController (`/recallTest`)
+
+向量召回前端测试页面专用接口。MVP 阶段不加鉴权。
+
+### 支持的 configCode
+
+| configCode | 说明 |
+|---|---|
+| `VIDEO_TOPIC` | 选题维度检索(默认) |
+| `VIDEO_INSPIRATION` | 灵感点维度检索 |
+
+> 不传 `configCode` 时默认使用 `VIDEO_TOPIC`。
+
+---
+
+### 2.1 获取视频基础详情 (Tab1)
+
+```
+GET /recallTest/videoDetail
+```
+
+**用途**: 查询单个视频的基础信息,用于测试页面 Tab1 展示。
+
+**Query Parameters**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `videoId` | `Long` | 是 | 视频ID |
+
+**示例请求**:
+
+```
+GET /recallTest/videoDetail?videoId=10086
+```
+
+**Response**: `data` 为 `VideoBasicVO`:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "videoId": 10086,
+    "title": "2025春季妆容教程",
+    "videoUrl": "https://example.com/video/10086.mp4",
+    "cover": "https://example.com/cover/10086.jpg",
+    "playCount": "--"
+  }
+}
+```
+
+**VideoBasicVO 字段说明**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `videoId` | `Long` | 视频ID |
+| `title` | `String` | 视频标题 |
+| `videoUrl` | `String` | 视频播放地址 |
+| `cover` | `String` | 封面图URL |
+| `playCount` | `String` | 播放量。长视频API当前不返回播放量,值为 `"--"` |
+
+---
+
+### 2.2 文本召回 (Tab2)
+
+```
+POST /recallTest/matchByText
+```
+
+**用途**: 输入自由文本,在向量库中检索语义最相似的 Top-N 个视频/素材/长文。
+
+**Request Body** (`application/json`):
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `queryText` | `String` | 是 | 查询文本,将向量化后在指定维度检索 |
+| `configCode` | `String` | 否 | 向量配置编码,不传默认 `VIDEO_TOPIC`。支持: `VIDEO_TOPIC` / `VIDEO_INSPIRATION` |
+| `topN` | `Integer` | 否 | 返回 Top-N 结果,默认 `10` |
+
+**Request 示例**:
+
+```json
+{
+  "queryText": "美妆护肤教程",
+  "configCode": "VIDEO_TOPIC",
+  "topN": 20
+}
+```
+
+**Response**: `data` 为 `RecallResultVO`:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "items": [
+      {
+        "id": 10001,
+        "modality": "VIDEO",
+        "configCode": "VIDEO_TOPIC",
+        "score": 0.9523,
+        "title": "春季护肤全攻略",
+        "cover": "https://example.com/cover/10001.jpg",
+        "videoUrl": "https://example.com/video/10001.mp4",
+        "imageList": null,
+        "bodyText": null,
+        "playCount": "--",
+        "exposure": "--",
+        "ctr": "--",
+        "readCount": "--",
+        "rov": "--",
+        "text": "美妆护肤教程 春季妆容",
+        "videoDetail": {
+          "topic": "护肤教程",
+          "灵感点": "产品成分解析",
+          "灵感点-实质": [
+            { "word": "成分", "score": 0.92 }
+          ],
+          "关键点": "敏感肌适用",
+          "关键点-实质": [
+            { "word": "敏感肌", "score": 0.89 }
+          ],
+          "目的点": "提升转化",
+          "目的点-实质": [
+            { "word": "购买", "score": 0.87 }
+          ]
+        }
+      },
+      {
+        "id": 20005,
+        "modality": "MATERIAL",
+        "configCode": "VIDEO_TOPIC",
+        "score": 0.8912,
+        "title": "护肤品种草图文",
+        "cover": "https://example.com/cover/20005.jpg",
+        "videoUrl": null,
+        "imageList": ["https://example.com/material/20005_1.jpg", "https://example.com/material/20005_2.jpg"],
+        "bodyText": null,
+        "playCount": "--",
+        "exposure": "--",
+        "ctr": "--",
+        "readCount": "--",
+        "rov": "--",
+        "text": "护肤品种草推荐",
+        "videoDetail": { }
+      }
+    ],
+    "videoCount": 1,
+    "materialCount": 1,
+    "articleCount": 0,
+    "total": 2
+  }
+}
+```
+
+**RecallResultVO 字段说明**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `items` | `List<VideoMatchEnrichedVO>` | 召回结果列表(已 enrich,含模态信息) |
+| `videoCount` | `int` | 命中视频数量 |
+| `materialCount` | `int` | 命中素材(图文)数量 |
+| `articleCount` | `int` | 命中长文数量 |
+| `total` | `int` | 总召回条数 |
+
+**VideoMatchEnrichedVO 单条结果字段说明**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `id` | `Long` | 业务ID。视频时 = `wx_video.id`,素材时 = `channelContentId` 数值化 |
+| `modality` | `Modality` 枚举 | 模态类型: `VIDEO` / `MATERIAL` / `ARTICLE` |
+| `configCode` | `String` | 命中的向量配置编码 |
+| `score` | `Double` | 余弦相似度分值(0~1) |
+| `title` | `String` | 标题 |
+| `cover` | `String` | 封面/缩略图URL |
+| `videoUrl` | `String` | 视频播放地址(仅 `modality=VIDEO` 时有值,其余为 `null`) |
+| `imageList` | `List<String>` | 图片列表(仅 `modality=MATERIAL` 时有值,其余为 `null`) |
+| `bodyText` | `String` | 正文(仅 `modality=ARTICLE` 时有值,其余为 `null`) |
+| `playCount` | `String` | 播放量,默认 `"--"` |
+| `exposure` | `String` | 曝光量,默认 `"--"` |
+| `ctr` | `String` | CTR,默认 `"--"` |
+| `readCount` | `String` | 阅读数,默认 `"--"` |
+| `rov` | `String` | ROV,默认 `"--"` |
+| `text` | `String` | 向量化原文 |
+| `videoDetail` | `Map<String, Object>` | 视频解构详情 KV(来源 Redis `recall:vid_decode:{vid}`),含 topic、灵感点、关键点、目的点及其"实质-分词" |
+
+**Modality 枚举映射**:
+
+| 枚举值 | 对应 content_type | 说明 |
+|---|---|---|
+| `VIDEO` | `3` | 视频 |
+| `MATERIAL` | `2` | 图文素材 |
+| `ARTICLE` | `1` | 长文 |
+
+> `content_type` 缺省或未知时默认按 `VIDEO` 处理。
+
+---
+
+### 2.3 获取视频解构层级 (Tab1 解构树)
+
+```
+GET /recallTest/deconstructPoints
+```
+
+**用途**: 获取视频的解构层级数据(选题 + 高价值点),用于前端解构树组件递归渲染。数据来源: Singapore RDS → Python 解析筛选 → 国内 Redis。
+
+**Query Parameters**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `videoId` | `Long` | 是 | 视频ID/素材ID |
+
+**示例请求**:
+
+```
+GET /recallTest/deconstructPoints?videoId=10086
+```
+
+**Response**: `data` 为 `DeconstructPointsVO`:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "vid": 10086,
+    "title": "2025春季妆容教程",
+    "videoUrl": "https://example.com/video/10086.mp4",
+    "htmlUrl": "https://example.com/viz/10086.html",
+    "topic": "美妆教程-春季妆容",
+    "highValuePoints": [
+      {
+        "id": "inspiration_1",
+        "type": "灵感点",
+        "name": "春季流行色系运用",
+        "essences": [
+          { "word": "春季", "score": 0.95 },
+          { "word": "色系", "score": 0.91 },
+          { "word": "流行", "score": 0.86 }
+        ]
+      },
+      {
+        "id": "kp_abc123",
+        "type": "关键点",
+        "name": "底妆持久度关键步骤",
+        "essences": [
+          { "word": "持久", "score": 0.93 },
+          { "word": "底妆", "score": 0.88 }
+        ]
+      },
+      {
+        "id": "purpose_1",
+        "type": "目的点",
+        "name": "提升完播率与互动",
+        "essences": [
+          { "word": "完播", "score": 0.82 },
+          { "word": "互动", "score": 0.80 }
+        ]
+      }
+    ]
+  }
+}
+```
+
+**DeconstructPointsVO 字段说明**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `vid` | `Long` | 视频ID |
+| `title` | `String` | 视频标题 |
+| `videoUrl` | `String` | 视频播放地址 |
+| `htmlUrl` | `String` | 带权重的可视化页面URL |
+| `topic` | `String` | 最终选题(格式: `最终选题.选题`) |
+| `highValuePoints` | `List<HighValuePoint>` | 实质≥0.8 的高价值解构点列表 |
+
+**HighValuePoint 子结构**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `id` | `String` | 业务侧ID,格式如 `inspiration_1` / `purpose_1` / `kp_xxxxxx` |
+| `type` | `String` | 点类型: `灵感点` / `目的点` / `关键点` |
+| `name` | `String` | 该点的描述名称 |
+| `essences` | `List<EssenceWord>` | 拆解出的"实质"分词列表(score ≥ 0.8) |
+
+**EssenceWord 子结构**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `word` | `String` | 实质词 |
+| `score` | `Double` | 词级贡献度(0~1,越大越重要) |
+
+---
+
+### 2.4 按视频ID召回相似内容 (Tab1 解构节点点击触发)
+
+```
+POST /recallTest/matchByVideoId
+```
+
+**用途**: 以指定视频/素材为参考,召回与之在指定维度(选题/灵感点)上最相似的内容。典型场景: 用户在解构树上看到某个节点,点击后查询与该节点对应的高价值点相似的内容。
+
+**Request Body** (`application/json`):
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `videoId` | `Long` | 是 | 视频ID 或 `channelContentId` 数值化后的值 |
+| `configCode` | `String` | 否 | 向量配置编码,不传默认 `VIDEO_TOPIC`。支持: `VIDEO_TOPIC` / `VIDEO_INSPIRATION` |
+| `topN` | `Integer` | 否 | 返回 Top-N 结果,默认 `10` |
+
+**Request 示例**:
+
+```json
+{
+  "videoId": 10086,
+  "configCode": "VIDEO_INSPIRATION",
+  "topN": 15
+}
+```
+
+**Response**: `data` 为 `RecallResultVO`,结构与 2.2 接口完全一致。
+
+---
+
+### 2.5 获取视频AI理解结果 (Tab1)
+
+```
+GET /recallTest/aiUnderstanding
+```
+
+**用途**: 获取视频的 AI 理解结果(选题、主题、关键词、口播文案等)。数据来源: ODPS `loghubods.result_log` → DataWorks 同步 Job → 本地表 `video_ai_understanding`。
+
+> **重要**: MVP 阶段本地表可能为空,此时 `data` 的 field 全部为 `null`,前端需展示"AI理解数据未就绪,等待同步Job"。**严禁后端伪造任何字段返回值。**
+
+**Query Parameters**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `videoId` | `Long` | 是 | 视频ID |
+
+**示例请求**:
+
+```
+GET /recallTest/aiUnderstanding?videoId=10086
+```
+
+**Response (数据就绪时)**: `data` 为 `AIUnderstandingVO`:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "videoId": 10086,
+    "contentTopic": "美妆护肤",
+    "videoTheme": "春季妆容教程",
+    "videoKeywords": "春季,妆容,护肤,教程",
+    "videoNarration": "大家好,今天我们来分享一款春季日常妆容...",
+    "dt": "2026050712"
+  }
+}
+```
+
+**Response (数据未就绪时)**:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": null
+}
+```
+
+> 前端在收到 `data == null` 时应展示"AI理解数据未就绪"占位状态,不可报错。
+
+**AIUnderstandingVO 字段说明**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `videoId` | `Long` | 视频ID |
+| `contentTopic` | `String` | 内容选题(AI识别) |
+| `videoTheme` | `String` | 视频主题 |
+| `videoKeywords` | `String` | 视频关键词(逗号分隔) |
+| `videoNarration` | `String` | 视频口播文案(ASR识别文本) |
+| `dt` | `String` | 数据所属分区,格式 `yyyyMMddHH`(如 `2026050712` 表示 2026-05-07 12时) |
+
+---
+
+## 三、MaterialController (`/material`)
+
+素材向量化搜索接口。支持素材入库(解构 + 异步向量化)和相似素材搜索。
+
+### 3.1 素材入库
+
+```
+POST /material/submit
+```
+
+**用途**: 提交素材进行解构和异步向量化入库。
+
+**Request Body** (`application/json`):
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `channelContentId` | `String` | 否 | 素材业务ID |
+| `contentType` | `Integer` | 否 | 内容类型: `1` = 长文, `2` = 图文, `3` = 视频 |
+| `title` | `String` | 否 | 素材标题 |
+| `bodyText` | `String` | 否 | 素材正文/描述 |
+| `videoUrl` | `String` | 否 | 视频地址(可选) |
+| `imageList` | `List<String>` | 否 | 图片列表(可选) |
+| `channelAccountId` | `String` | 否 | 作者ID(可选) |
+| `channelAccountName` | `String` | 否 | 作者名称(可选) |
+
+**Request 示例**:
+
+```json
+{
+  "channelContentId": "mat_001",
+  "contentType": 2,
+  "title": "护肤品图文种草素材",
+  "bodyText": "这款精华液含有烟酰胺成分...",
+  "imageList": ["https://example.com/img1.jpg", "https://example.com/img2.jpg"],
+  "channelAccountId": "author_001",
+  "channelAccountName": "护肤小达人"
+}
+```
+
+**Response**: `data` 为 `String`,返回任务唯一标识:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": "<taskId>"
+}
+```
+
+---
+
+### 3.2 素材相似搜索
+
+```
+POST /material/matchTopN
+```
+
+**用途**: 在素材向量库中检索语义最相似的 Top-N 个素材。
+
+**Request Body** (`application/json`):
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `queryText` | `String` | 否 | 查询文本,将被向量化后检索 |
+| `queryVector` | `List<Float>` | 否 | 直接传入查询向量,优先级高于 `queryText` |
+| `channelContentId` | `String` | 否 | 业务内容ID,用于复用历史向量(可选) |
+| `configCode` | `String` | 否 | 配置编码。传 `ALL` 可搜索所有启用的配置维度 |
+| `topN` | `Integer` | 否 | 返回 Top-N 结果,默认 `10` |
+
+**Request 示例**:
+
+```json
+{
+  "queryText": "烟酰胺精华液功效",
+  "configCode": "ALL",
+  "topN": 10
+}
+```
+
+**Response**: `data` 为 `List<MaterialMatchResult>`:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": [
+    {
+      "configCode": "VIDEO_TOPIC",
+      "contentId": 20001,
+      "channelContentId": "mat_005",
+      "score": 0.9213,
+      "title": "烟酰胺美白精华种草",
+      "sourceText": "烟酰胺是一种高效美白成分..."
+    }
+  ]
+}
+```
+
+**MaterialMatchResult 字段说明**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `configCode` | `String` | 命中的配置编码 |
+| `contentId` | `Long` | deconstruct_content.id |
+| `channelContentId` | `String` | 素材业务ID |
+| `score` | `Double` | 余弦相似度分值(0~1) |
+| `title` | `String` | 素材标题 |
+| `sourceText` | `String` | 命中的向量原文 |
+
+---
+
+## 四、FileController (`/file`)
+
+OSS 文件上传与签名接口。所有端点均配置 `@CrossOrigin(origins = "*")`。
+
+### 4.1 文件上传
+
+```
+POST /file/upload
+```
+
+**用途**: 上传文件到阿里云 OSS。
+
+**Request**: `multipart/form-data`
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `file` | `MultipartFile` | 是 | 上传的文件 |
+| `fileUri` | `String` | 否 | 文件存储路径。不传则自动生成 `video-vector/{timestamp}_{random}.{ext}` |
+| `fileType` | `EnumFileType` | 是 | 文件类型枚举 |
+
+**Response**: `data` 为 `FileInfo`:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "fileName": "example.jpg",
+    "fileUrl": "https://bucket.oss-cn-hangzhou.aliyuncs.com/video-vector/123456_abc.jpg",
+    "fileSize": 102400.0
+  }
+}
+```
+
+**FileInfo 字段说明**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `fileName` | `String` | 文件名 |
+| `fileUrl` | `String` | 文件访问URL |
+| `fileSize` | `Double` | 文件大小(字节) |
+
+---
+
+### 4.2 获取OSS上传签名
+
+```
+POST /file/signature
+```
+
+**用途**: 获取阿里云 OSS 上传策略签名,供前端直传 OSS 使用。
+
+**Request Body** (`application/json`):
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `fileType` | `Integer` | 是 | 文件类型: `1` = 图片, `2` = 视频, `3` = 音频, `4` = 文件, `5` = GIF, `6` = 字幕 |
+
+> 该参数继承自 `VideoApiBaseParam`,包含大量通用参数(laginUid, token, versionCode 等),此处仅列出必填的 `fileType`。
+
+**Request 示例**:
+
+```json
+{
+  "fileType": 1
+}
+```
+
+**Response**: `data` 为 `SignatureVO`:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "accessId": "LTAI5xxx",
+    "policy": "eyJleHBpcmF0aW9uIjo...",
+    "signature": "abc123...",
+    "fileName": "video-vector/abc.jpg",
+    "host": "https://bucket.oss-cn-hangzhou.aliyuncs.com",
+    "expire": "2026-05-09T12:00:00Z"
+  }
+}
+```
+
+**SignatureVO 字段说明**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `accessId` | `String` | OSS AccessKeyId |
+| `policy` | `String` | Base64 编码的上传策略 |
+| `signature` | `String` | 策略签名 |
+| `fileName` | `String` | 随机生成的文件名 |
+| `host` | `String` | OSS Bucket 域名 |
+| `expire` | `String` | 签名过期时间 |
+
+---
+
+### 4.3 获取STS临时令牌
+
+```
+POST /file/getTempStsToken
+```
+
+**用途**: 获取阿里云 STS 临时访问令牌,有效期为 15 分钟。用于前端安全直传 OSS,降低安全风险。
+
+**Request Body** (`application/json`):
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `fileType` | `Integer` | 是 | 文件类型: `1` = 图片, `2` = 视频, `3` = 音频, `4` = 文件, `5` = GIF, `6` = 字幕 |
+| `uploadId` | `String` | 否 | 上传ID |
+
+> 该参数继承自 `VideoApiBaseParam`,包含大量通用参数,此处仅列出业务参数。
+
+**Request 示例**:
+
+```json
+{
+  "fileType": 2,
+  "uploadId": "upload_001"
+}
+```
+
+**Response**: `data` 为 `StsTokenVO`:
+
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "Expiration": "2026-05-09T10:15:00Z",
+    "AccessKeyId": "STS.xxx",
+    "AccessKeySecret": "yyy",
+    "SecurityToken": "zzz",
+    "RequestId": "req-001",
+    "FileName": "video-vector/123456_abc.mp4",
+    "Host": "https://bucket.oss-cn-hangzhou.aliyuncs.com",
+    "Hosts": ["https://bucket.oss-cn-hangzhou.aliyuncs.com"],
+    "Bucket": "bucket-name",
+    "Region": "oss-cn-hangzhou",
+    "Cname": false,
+    "serverTimestamp": 1746782100000
+  }
+}
+```
+
+**StsTokenVO 字段说明**:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `Expiration` | `String` | 令牌过期时间 |
+| `AccessKeyId` | `String` | STS AccessKeyId |
+| `AccessKeySecret` | `String` | STS AccessKeySecret |
+| `SecurityToken` | `String` | STS 安全令牌 |
+| `RequestId` | `String` | 请求ID |
+| `FileName` | `String` | 随机生成的文件名 |
+| `Host` | `String` | OSS Bucket 域名 |
+| `Hosts` | `String[]` | OSS Bucket 域名列表 |
+| `Bucket` | `String` | OSS Bucket 名称 |
+| `Region` | `String` | OSS 区域 |
+| `Cname` | `Boolean` | 是否使用 CNAME |
+| `serverTimestamp` | `Long` | 服务器时间戳(毫秒) |
+
+---
+
+## 五、HealthController (`/`)
+
+### 5.1 健康检查
+
+```
+GET /healthcheck
+```
+
+**用途**: 服务探活端点,用于负载均衡/容器编排的健康检查。
+
+**请求参数**: 无。
+
+**示例请求**:
+
+```
+GET /healthcheck
+```
+
+**Response**: 返回纯文本 `ok` (非 `CommonResponse` 包装):
+
+```
+ok
+```
+
+---
+
+## 六、XxlJobController (`/job`)
+
+定时任务触发器,供 XXL-Job 调度中心回调。所有接口无请求参数,返回 `CommonResponse<Void>`。
+
+### 6.1 视频向量化任务
+
+```
+GET /job/vectorVideoJob
+```
+
+**用途**: 触发视频向量化任务。
+
+### 6.2 AIGC视频向量化任务
+
+```
+GET /job/aigcVideoVectorJob
+```
+
+**用途**: 触发 AIGC 视频向量化任务。
+
+### 6.3 结果日志视频向量化任务
+
+```
+GET /job/resultLogVideoVectorJob
+```
+
+**用途**: 触发结果日志视频向量化任务。
+
+### 6.4 全量视频向量化任务
+
+```
+GET /job/allVideoVectorJob
+```
+
+**用途**: 触发全量视频向量化任务。
+
+### 6.5 重试解构任务
+
+```
+GET /job/retryDeconstructJob
+```
+
+**用途**: 重试失败的解构任务。
+
+### 6.6 检查素材解构任务
+
+```
+GET /job/checkMaterialDeconstructJob
+```
+
+**用途**: 触发素材解构状态检查。
+
+### 6.7 素材向量化任务
+
+```
+GET /job/vectorMaterialJob
+```
+
+**用途**: 触发素材向量化任务。
+
+### 6.8 同步视频详情任务
+
+```
+GET /job/syncVideoDetailJob
+```
+
+**用途**: 触发视频详情同步任务(从外部数据源同步至本地)。
+
+### 6.9 视频标题向量化任务
+
+```
+GET /job/videoTitleVectorJob
+```
+
+**用途**: 触发视频标题向量化任务。
+
+### 6.10 同步AI理解数据任务
+
+```
+GET /job/syncAiUnderstandingJob
+```
+
+**用途**: 触发 AI 理解数据同步任务(从 ODPS → DataWorks → 本地表)。
+
+### 6.11 渠道需求匹配任务
+
+```
+GET /job/channelDemandMatchJob
+```
+
+**用途**: 触发渠道需求匹配计算任务。
+
+---
+
+## 七、数据流与架构说明
+
+### 解构数据管线的来源链路
+
+```
+Singapore RDS (aigc_topic_decode_task_result)
+  → Python 解析脚本(筛选实质 ≥ 0.8 的高价值点)
+    → 国内 Redis (key: recall:vid_decode:{vid})
+      → Java 后端 (DeconstructPointsVO / VideoMatchResult)
+        → 前端渲染
+```
+
+### 向量召回流程
+
+```
+输入(queryText / videoId / channelContentId)
+  → 获取或计算查询向量
+    → Milvus/ES 向量检索 (按 configCode 指定维度)
+      → 返回 Top-N 候选 vid
+        → enrich: 从 Redis recall:vid_decode:{vid} 读取解构详情
+          → 组装 RecallResultVO / VideoMatchResult 返回
+```
+
+### AI理解数据链路
+
+```
+ODPS (loghubods.result_log)
+  → DataWorks 同步 Job
+    → 本地 MySQL (video_ai_understanding 表)
+      → Java 后端查询
+        → 前端渲染
+```
+
+---
+
+## 八、接口总览表
+
+| 接口 | Method | 路径 | 说明 |
+|---|---|---|---|
+| 触发内容解构 | `POST` | `/videoSearch/deconstruct` | 发起AI解构任务,返回 taskId |
+| 查询解构结果 | `POST` | `/videoSearch/getDeconstructResult` | 按 taskId/channelContentId 查结果 |
+| 按内容ID查解构 | `GET` | `/videoSearch/getDeconstructResultByChannelContentId` | 简化查询,仅需 channelContentId |
+| 查询解构结果(精简版) | `POST` | `/videoSearch/getDeconstructResultMini` | 返回精简后的解构结果 |
+| 相似视频匹配 | `POST` | `/videoSearch/matchTopNVideo` | Top-N 向量召回相似视频 |
+| 召回并综合评分 | `POST` | `/videoSearch/recallWithScore` | 召回后按 sim + rov 综合分排序 |
+| 查询渠道需求匹配 | `POST` | `/videoSearch/queryDemandMatchResult` | 按日期/渠道/维度查询匹配视频 |
+| 获取所有配置码 | `GET` | `/videoSearch/getAllConfigCodes` | 查询支持的向量配置维度 |
+| 视频基础详情 | `GET` | `/recallTest/videoDetail` | 查单视频基础信息(Tab1) |
+| 文本召回 | `POST` | `/recallTest/matchByText` | 自由文本语义检索(Tab2) |
+| 解构层级 | `GET` | `/recallTest/deconstructPoints` | 视频解构树数据(Tab1) |
+| 按视频ID召回 | `POST` | `/recallTest/matchByVideoId` | 相似视频召回(Tab1节点触发) |
+| AI理解结果 | `GET` | `/recallTest/aiUnderstanding` | 视频AI理解数据(Tab1) |
+| 素材入库 | `POST` | `/material/submit` | 提交素材进行解构+向量化 |
+| 素材相似搜索 | `POST` | `/material/matchTopN` | 检索相似素材 |
+| 文件上传 | `POST` | `/file/upload` | 上传文件至阿里云OSS |
+| 获取OSS签名 | `POST` | `/file/signature` | 获取OSS上传策略签名 |
+| 获取STS令牌 | `POST` | `/file/getTempStsToken` | 获取STS临时访问令牌(15min) |
+| 健康检查 | `GET` | `/healthcheck` | 服务探活,返回 "ok" |
+| 视频向量化Job | `GET` | `/job/vectorVideoJob` | 触发视频向量化任务 |
+| AIGC视频向量化Job | `GET` | `/job/aigcVideoVectorJob` | 触发AIGC视频向量化 |
+| 结果日志向量化Job | `GET` | `/job/resultLogVideoVectorJob` | 触发结果日志向量化 |
+| 全量向量化Job | `GET` | `/job/allVideoVectorJob` | 触发全量视频向量化 |
+| 重试解构Job | `GET` | `/job/retryDeconstructJob` | 重试失败解构任务 |
+| 素材解构检查Job | `GET` | `/job/checkMaterialDeconstructJob` | 检查素材解构状态 |
+| 素材向量化Job | `GET` | `/job/vectorMaterialJob` | 触发素材向量化 |
+| 同步视频详情Job | `GET` | `/job/syncVideoDetailJob` | 同步视频详情数据 |
+| 标题向量化Job | `GET` | `/job/videoTitleVectorJob` | 触发标题向量化 |
+| 同步AI理解Job | `GET` | `/job/syncAiUnderstandingJob` | 同步AI理解数据 |
+| 渠道需求匹配Job | `GET` | `/job/channelDemandMatchJob` | 触发渠道需求匹配计算 |
+
+---
+
+## 九、错误状态码
+
+| code | 含义 | 典型场景 |
+|---|---|---|
+| `0` | 成功 | — |
+| 非 `0` | 业务异常 | 参数校验失败、向量库不可用、查询超时等,具体 `msg` 字段会描述原因 |

+ 74 - 0
app/domains/recommend/demand_recommend/entrance.py

@@ -50,6 +50,80 @@ class DemandRecommendTask(DemandRecommendConst):
         except Exception:
             pass  # 通知失败不影响主流程
 
+    # ── 标题匹配 ──
+
+    async def deal_titles(
+        self,
+        titles: List[str],
+        config_code: str = None,
+        top_n: int = 10,
+        use_scoring: bool = False,
+        alpha: float = 0.6,
+        sim_min: float = 0.7,
+        save_to_db: bool = False,
+    ) -> Dict:
+        """标题匹配入口:批量标题 → 匹配视频。
+
+        Args:
+            titles: 标题文本列表
+            config_code: 向量维度编码,默认 VIDEO_TOPIC
+            top_n: 每个标题返回数量
+            use_scoring: True 走 recallWithScore(sim+rov 综合排序)
+            alpha: 仅 use_scoring=True 时生效
+            sim_min: 仅 use_scoring=True 时生效
+            save_to_db: 是否将匹配结果写入 DB
+        """
+        if not titles:
+            return {
+                "total_titles": 0,
+                "matched_titles": 0,
+                "total_videos": 0,
+            }
+
+        valid_titles = [t for t in titles if t and t.strip()]
+        await self.log_service.log(
+            contents={
+                "task": "title_video_match",
+                "status": "start",
+                "total_titles": len(valid_titles),
+                "config_code": config_code,
+                "use_scoring": use_scoring,
+            }
+        )
+
+        results_map = await self.engine.match_titles_batch(
+            titles=valid_titles,
+            config_code=config_code,
+            top_n=top_n,
+            use_scoring=use_scoring,
+            alpha=alpha,
+            sim_min=sim_min,
+        )
+
+        matched_titles = sum(1 for v in results_map.values() if v)
+        all_results: List[MatchResult] = []
+        for r in results_map.values():
+            all_results.extend(r)
+
+        if save_to_db and all_results:
+            await self.mapper.save_match_results(all_results)
+
+        summary = {
+            "total_titles": len(valid_titles),
+            "matched_titles": matched_titles,
+            "total_videos": len(all_results),
+        }
+
+        await self.log_service.log(
+            contents={
+                "task": "title_video_match",
+                "status": "done",
+                **summary,
+            }
+        )
+
+        return summary
+
     # ── 主入口 ──
 
     async def deal(

+ 103 - 0
app/domains/recommend/demand_recommend/match_engine.py

@@ -15,6 +15,7 @@ from ._utils import (
     MatchStrategy,
     merge_multi_recall,
     parse_recall_items,
+    parse_scored_items,
 )
 
 
@@ -124,3 +125,105 @@ class DemandVideoMatchEngine:
         }
         resp = await client.post(url, json=body)
         return resp if isinstance(resp, dict) else {}
+
+    # ── 标题匹配 ──
+
+    async def _recall_with_score(
+        self,
+        client: AsyncHttpClient,
+        query_text: str,
+        config_code: str,
+        top_n: int,
+        alpha: float = 0.6,
+        sim_min: float = 0.7,
+    ) -> Dict[str, Any]:
+        """调用 /videoSearch/recallWithScore,返回综合考虑 sim+rov 的排序结果"""
+        url = f"{self.base_url}{DemandRecommendConst.ApiPath.RECALL_WITH_SCORE}"
+        body = {
+            "queryText": query_text,
+            "configCode": config_code,
+            "topN": top_n,
+            "alpha": alpha,
+            "simMin": sim_min,
+        }
+        resp = await client.post(url, json=body)
+        return resp if isinstance(resp, dict) else {}
+
+    async def match_by_title(
+        self,
+        title: str,
+        config_code: str = None,
+        top_n: int = 10,
+        use_scoring: bool = False,
+        alpha: float = 0.6,
+        sim_min: float = 0.7,
+    ) -> List[MatchResult]:
+        """单条标题匹配:以标题文本在向量库中检索最相似的视频。
+
+        Args:
+            title: 标题文本
+            config_code: 向量维度编码,默认 VIDEO_TOPIC
+            top_n: 返回数量,默认 10
+            use_scoring: True 走 recallWithScore(sim+rov 综合排序)
+            alpha: 仅 use_scoring=True 时生效,相关性权重 0~1
+            sim_min: 仅 use_scoring=True 时生效,相似度粗筛阈值
+        """
+        if not title or not title.strip():
+            return []
+
+        code = config_code or DemandRecommendConst.DEFAULT_CONFIG_CODE
+        strategy = MatchStrategy(
+            demand_id="",
+            experiment_id="",
+            dt="",
+            match_methods=[MatchMethod.TEXT],
+            config_codes=[code],
+            top_n=top_n,
+            query_text=title.strip(),
+        )
+
+        async with AsyncHttpClient(timeout=DemandRecommendConst.API_TIMEOUT) as client:
+            if use_scoring:
+                resp = await self._recall_with_score(
+                    client, title.strip(), code, top_n, alpha, sim_min
+                )
+                return parse_scored_items(resp, strategy, code)
+            else:
+                resp = await self._match_by_text(
+                    client, title.strip(), code, top_n
+                )
+                return parse_recall_items(resp, strategy, MatchMethod.TEXT, code)
+
+    async def match_titles_batch(
+        self,
+        titles: List[str],
+        config_code: str = None,
+        top_n: int = 10,
+        use_scoring: bool = False,
+        alpha: float = 0.6,
+        sim_min: float = 0.7,
+    ) -> Dict[str, List[MatchResult]]:
+        """批量标题匹配:串行逐条匹配,返回 {title: [MatchResult, ...]}。
+
+        Args:
+            titles: 标题文本列表
+            config_code: 向量维度编码,默认 VIDEO_TOPIC
+            top_n: 每个标题返回数量,默认 10
+            use_scoring: True 走 recallWithScore
+            alpha: 仅 use_scoring=True 时生效
+            sim_min: 仅 use_scoring=True 时生效
+        """
+        results: Dict[str, List[MatchResult]] = {}
+        for title in titles:
+            key = title.strip() if title else ""
+            if not key:
+                results[title] = []
+                continue
+            try:
+                results[title] = await self.match_by_title(
+                    key, config_code=config_code, top_n=top_n,
+                    use_scoring=use_scoring, alpha=alpha, sim_min=sim_min,
+                )
+            except Exception:
+                results[title] = []
+        return results