Explorar el Código

Merge branch 'feature/luojunhui/20260608-article-stat' of Server/video-vector-server into master

luojunhui hace 1 día
padre
commit
f99349abe2

+ 13 - 0
core/src/main/java/com/tzld/videoVector/dao/mapper/pgVector/ext/ArticleQualityMapperExt.java

@@ -0,0 +1,13 @@
+package com.tzld.videoVector.dao.mapper.pgVector.ext;
+
+import com.tzld.videoVector.model.po.pgVector.ArticleQuality;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface ArticleQualityMapperExt {
+
+    int batchUpsert(@Param("list") List<ArticleQuality> list);
+
+    List<ArticleQuality> selectByContentIds(@Param("contentIds") List<String> contentIds);
+}

+ 330 - 0
core/src/main/java/com/tzld/videoVector/job/ArticleQualitySyncJob.java

@@ -0,0 +1,330 @@
+package com.tzld.videoVector.job;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.tzld.videoVector.dao.mapper.pgVector.ext.ArticleQualityMapperExt;
+import com.tzld.videoVector.model.po.pgVector.ArticleQuality;
+import com.tzld.videoVector.util.ArticleQualityCalculator;
+import com.tzld.videoVector.util.OdpsUtil;
+import com.xxl.job.core.biz.model.ReturnT;
+import com.xxl.job.core.handler.annotation.XxlJob;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Component
+public class ArticleQualitySyncJob {
+
+    private static final Logger log = LoggerFactory.getLogger(ArticleQualitySyncJob.class);
+
+    private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
+    private static final int DB_BATCH_SIZE = 200;
+
+    @Resource
+    private ArticleQualityMapperExt articleQualityMapperExt;
+
+    @XxlJob("articleQualityJob")
+    public ReturnT<String> articleQualityJob(String param) {
+        log.info("===== articleQualityJob 开始, param: {} =====", param);
+
+        double wRead = parseParamDouble(param, "wRead", 0.4);
+        double wOpen = parseParamDouble(param, "wOpen", 0.3);
+        double wFission = parseParamDouble(param, "wFission", 0.3);
+        int confidenceThreshold = (int) parseParamDouble(param, "confidenceThreshold", 3);
+        boolean dryRun = parseParamBool(param, "dryRun");
+        String odpsDt = parseParamString(param, "dt", LocalDate.now().minusDays(1).format(DT_FMT));
+        int maxRows = (int) parseParamDouble(param, "maxRows", 0);
+
+        if (!odpsDt.matches("\\d{8}")) {
+            log.error("odpsDt 格式非法,期望 yyyyMMdd: {}", odpsDt);
+            return ReturnT.FAIL;
+        }
+        if (maxRows < 0) {
+            log.error("maxRows 不能为负数: {}", maxRows);
+            return ReturnT.FAIL;
+        }
+
+        String safeOdpsDt = odpsDt.replace("'", "''");
+
+        String dt = LocalDate.now().format(DT_FMT);
+        log.info("权重: r={} o={} f={}, 置信度阈值: {}, ODPS分区dt={}, 写入dt={}",
+                wRead, wOpen, wFission, confidenceThreshold, safeOdpsDt, dt);
+
+        // 确认分区有数据
+        String probeSql = "SELECT COUNT(*) AS cnt FROM loghubods.article_title_his_cache WHERE dt = '" + safeOdpsDt + "' AND type = '9'";
+        log.info("探针 SQL: {}", probeSql);
+        try {
+            long[] rowCount = {0};
+            OdpsUtil.getOdpsDataStream(probeSql, record -> {
+                rowCount[0] = record.getBigint("cnt") != null ? record.getBigint("cnt") : 0;
+            });
+            log.info("探针: dt={} 总行数={}", odpsDt, rowCount[0]);
+            if (rowCount[0] == 0) {
+                log.warn("分区 dt={} 无数据,请确认分区值是否正确", odpsDt);
+                return ReturnT.FAIL;
+            }
+        } catch (Exception e) {
+            log.error("探针查询失败: {}", e.getMessage(), e);
+            return ReturnT.FAIL;
+        }
+
+        // 从 ODPS 流式读取
+        List<ArticleQuality> rawList = new ArrayList<>();
+        long[] totalRows = {0};
+        long[] parseFailCount = {0};
+        long[] emptyDataCount = {0};
+        long[] validCount = {0};
+
+        String sql = maxRows > 0
+                ? "SELECT source_id, his_publish_article_list "
+                    + "FROM loghubods.article_title_his_cache "
+                    + "WHERE dt = '" + safeOdpsDt + "' "
+                    + "AND type = '9' "
+                    + "AND his_publish_article_list IS NOT NULL "
+                    + "LIMIT " + maxRows
+                : "SELECT source_id, his_publish_article_list "
+                    + "FROM loghubods.article_title_his_cache "
+                    + "WHERE dt = '" + safeOdpsDt + "' "
+                    + "AND type = '9' "
+                    + "AND his_publish_article_list IS NOT NULL";
+
+        log.info("ODPS SQL: {}", sql);
+        try {
+            OdpsUtil.getOdpsDataStream(sql, record -> {
+                String contentId = record.getString("source_id");
+                String hisPublishStr = record.getString("his_publish_article_list");
+
+                if (contentId == null || contentId.isEmpty()) return;
+                if (hisPublishStr == null || hisPublishStr.isEmpty()) return;
+
+                if (totalRows[0] < 3) {
+                    String preview = hisPublishStr.length() > 500
+                            ? hisPublishStr.substring(0, 500) + "..."
+                            : hisPublishStr;
+                    log.info("[采样{}] contentId={}, json={}", totalRows[0], contentId, preview);
+                }
+
+                ArticleQuality aq = aggregateFromHisPublishList(contentId, hisPublishStr);
+                if (aq != null) {
+                    synchronized (rawList) { rawList.add(aq); }
+                    validCount[0]++;
+                } else {
+                    if (isJsonParseFail(hisPublishStr)) {
+                        parseFailCount[0]++;
+                        if (parseFailCount[0] <= 5) {
+                            String preview = hisPublishStr.length() > 300
+                                    ? hisPublishStr.substring(0, 300) + "..."
+                                    : hisPublishStr;
+                            log.info("[解析失败#{}] contentId={}, json={}", parseFailCount[0], contentId, preview);
+                        }
+                    } else {
+                        emptyDataCount[0]++;
+                    }
+                }
+                totalRows[0]++;
+                if (totalRows[0] % 10000 == 0) {
+                    log.info("[进度] {} 行, 有效={}, 解析失败={}, 无数据={}", totalRows[0], validCount[0], parseFailCount[0], emptyDataCount[0]);
+                }
+            });
+        } catch (Exception e) {
+            log.error("ODPS 查询异常: {}", e.getMessage(), e);
+            return ReturnT.FAIL;
+        }
+
+        log.info("[完成] 总行数={}, 有效={}, 解析失败={}, 无数据={}", totalRows[0], validCount[0], parseFailCount[0], emptyDataCount[0]);
+        if (rawList.isEmpty()) {
+            log.warn("无有效文章表现数据");
+            return ReturnT.FAIL;
+        }
+
+        // 计算质量分
+        ArticleQualityCalculator.calculateAll(rawList, wRead, wOpen, wFission, confidenceThreshold);
+
+        if (dryRun) {
+            log.info("===== DRY RUN 模式, 不写入DB =====");
+            printTopBottom(rawList);
+            return ReturnT.SUCCESS;
+        }
+
+        // 按 contentId 去重(ODPS 同 contentId 可能多行)
+        Map<String, ArticleQuality> deduped = new LinkedHashMap<>();
+        for (ArticleQuality aq : rawList) {
+            deduped.putIfAbsent(aq.getContentId(), aq);
+        }
+        List<ArticleQuality> list = new ArrayList<>(deduped.values());
+        log.info("[去重] {} → {} 条", rawList.size(), list.size());
+
+        // 分批写入
+        log.info("[写入DB] 开始, 共 {} 条, dt={}", list.size(), dt);
+        if (!list.isEmpty()) {
+            ArticleQuality sample = list.get(0);
+            log.info("[写入DB 采样] contentId={}, qualityScore={}, dt={}",
+                    sample.getContentId(), round2(sample.getQualityScore()), dt);
+        }
+        int totalUpserted = 0;
+        for (int i = 0; i < list.size(); i += DB_BATCH_SIZE) {
+            int end = Math.min(i + DB_BATCH_SIZE, list.size());
+            List<ArticleQuality> batch = list.subList(i, end);
+            for (ArticleQuality aq : batch) {
+                aq.setDt(dt);
+            }
+            int n = articleQualityMapperExt.batchUpsert(batch);
+            totalUpserted += n;
+            if ((i / DB_BATCH_SIZE) % 50 == 0) {
+                log.info("[写入DB 进度] {}/{}, 本批{}条, 累计{}条", end, list.size(), n, totalUpserted);
+            }
+        }
+        log.info("[写入DB 完成] upserted={}", totalUpserted);
+        return ReturnT.SUCCESS;
+    }
+
+    private ArticleQuality aggregateFromHisPublishList(String contentId, String hisPublishListJson) {
+        JSONArray publishList;
+        try {
+            publishList = JSON.parseArray(hisPublishListJson);
+        } catch (Exception e) {
+            log.info("contentId={} his_publish_article_list JSON 解析失败: {}", contentId, e.getMessage());
+            return null;
+        }
+        if (publishList == null || publishList.isEmpty()) {
+            return null;
+        }
+
+        long totalRead = 0;
+        double totalAvgRead = 0;
+        long totalFans = 0;
+        int maxItemIndex = -1;
+        double totalFirstLevel = 0;
+        double totalFission = 0;
+
+        for (int i = 0; i < publishList.size(); i++) {
+            JSONObject pub = publishList.getJSONObject(i);
+            if (pub == null) continue;
+
+            long viewCount = pub.getLongValue("viewCount");
+            totalRead += viewCount;
+
+            Double avgView = pub.getDouble("avgViewCount");
+            if (avgView != null) {
+                totalAvgRead += avgView;
+            }
+
+            // 取最新发文的粉丝量
+            int itemIndex = pub.getIntValue("itemIndex");
+            if (itemIndex > maxItemIndex) {
+                maxItemIndex = itemIndex;
+                totalFans = pub.getLongValue("fans");
+            }
+
+            JSONArray fissionList = pub.getJSONArray("articleDetailInfoList");
+            if (fissionList != null) {
+                double pubFirstLevel = 0;
+                double pubFission = 0;
+                for (int j = 0; j < fissionList.size(); j++) {
+                    JSONObject fi = fissionList.getJSONObject(j);
+                    if (fi == null) continue;
+                    pubFirstLevel += fi.getDoubleValue("firstLevel");
+                    pubFission += fi.getDoubleValue("fission0")
+                            + fi.getDoubleValue("fission1")
+                            + fi.getDoubleValue("fission2");
+                }
+                totalFirstLevel += pubFirstLevel;
+                totalFission += pubFission;
+            }
+        }
+
+        if (totalRead <= 0 && totalAvgRead <= 0) {
+            return null;
+        }
+
+        ArticleQuality aq = new ArticleQuality();
+        aq.setContentId(contentId);
+        aq.setTotalRead(totalRead);
+        aq.setAvgRead(totalAvgRead);
+        aq.setTotalFans(totalFans);
+        aq.setPublishCount(publishList.size());
+        aq.setOpenRate(totalRead > 0 ? totalFirstLevel / totalRead : 0);
+        aq.setFissionRate(totalFirstLevel > 0 ? totalFission / totalFirstLevel : 0);
+        return aq;
+    }
+
+    private static boolean isJsonParseFail(String json) {
+        try {
+            JSON.parseArray(json);
+            return false;
+        } catch (Exception e) {
+            return true;
+        }
+    }
+
+    private static double parseParamDouble(String param, String key, double defaultValue) {
+        if (param == null || param.isEmpty()) return defaultValue;
+        for (String part : param.split(",")) {
+            String[] kv = part.trim().split("=", 2);
+            if (kv.length == 2 && kv[0].trim().equals(key)) {
+                try { return Double.parseDouble(kv[1].trim()); } catch (NumberFormatException ignored) { }
+            }
+        }
+        return defaultValue;
+    }
+
+    private static String parseParamString(String param, String key, String defaultValue) {
+        if (param == null || param.isEmpty()) return defaultValue;
+        for (String part : param.split(",")) {
+            String[] kv = part.trim().split("=", 2);
+            if (kv.length == 2 && kv[0].trim().equals(key)) {
+                return kv[1].trim();
+            }
+        }
+        return defaultValue;
+    }
+
+    private static boolean parseParamBool(String param, String key) {
+        if (param == null || param.isEmpty()) return false;
+        for (String part : param.split(",")) {
+            String[] kv = part.trim().split("=", 2);
+            if (kv.length == 2 && kv[0].trim().equals(key)) {
+                return "true".equalsIgnoreCase(kv[1].trim());
+            }
+        }
+        return false;
+    }
+
+    private static void printTopBottom(List<ArticleQuality> list) {
+        List<ArticleQuality> sorted = new ArrayList<>(list);
+        sorted.sort((a, b) -> Double.compare(
+                b.getQualityScore() == null ? 0 : b.getQualityScore(),
+                a.getQualityScore() == null ? 0 : a.getQualityScore()));
+
+        int show = Math.min(5, sorted.size());
+        log.info("===== Top {} 高质量文章 =====", show);
+        for (int i = 0; i < show; i++) {
+            ArticleQuality aq = sorted.get(i);
+            log.info("[{}] contentId={}, qualityScore={}, readScore={}, openScore={}, fissionScore={}, conf={}",
+                    i + 1, aq.getContentId(), round2(aq.getQualityScore()),
+                    round2(aq.getReadScore()), round2(aq.getOpenScore()),
+                    round2(aq.getFissionScore()), round2(aq.getConfidence()));
+        }
+        log.info("===== Bottom {} 低质量文章 =====", show);
+        for (int i = sorted.size() - 1; i >= Math.max(0, sorted.size() - show); i--) {
+            ArticleQuality aq = sorted.get(i);
+            log.info("[{}] contentId={}, qualityScore={}, readScore={}, openScore={}, fissionScore={}, conf={}",
+                    sorted.size() - i, aq.getContentId(), round2(aq.getQualityScore()),
+                    round2(aq.getReadScore()), round2(aq.getOpenScore()),
+                    round2(aq.getFissionScore()), round2(aq.getConfidence()));
+        }
+    }
+
+    private static double round2(Double v) {
+        return ArticleQualityCalculator.round2(v);
+    }
+}

+ 3 - 0
core/src/main/java/com/tzld/videoVector/model/param/recall/BatchByTextParam.java

@@ -40,4 +40,7 @@ public class BatchByTextParam {
 
     /** 精排参数(从前端传入,覆盖后端默认值) */
     private RankingSpec ranking;
+
+    /** 指标数据日期维度(天),不传则使用 video.detail.metrics.days 配置 */
+    private Integer days;
 }

+ 3 - 0
core/src/main/java/com/tzld/videoVector/model/param/recall/MatchByTextParam.java

@@ -55,4 +55,7 @@ public class MatchByTextParam {
 
     /** 精排参数(从前端传入,覆盖后端默认值) */
     private RankingSpec ranking;
+
+    /** 指标数据日期维度(天),用于筛选 article_quality 的 dt 分区,不传则取最新分区 */
+    private Integer days;
 }

+ 9 - 0
core/src/main/java/com/tzld/videoVector/model/param/recall/RankingSpec.java

@@ -43,4 +43,13 @@ public class RankingSpec {
 
     /** 素材质量缺失策略:"group" | "shrink",默认 "group" */
     private String materialMissingStrategy;
+
+    /** 文章质量子维度权重——阅读,默认 0.4 */
+    private Double wRead;
+
+    /** 文章质量子维度权重——打开率,默认 0.3 */
+    private Double wOpen;
+
+    /** 文章质量子维度权重——裂变率,默认 0.3 */
+    private Double wFission;
 }

+ 30 - 0
core/src/main/java/com/tzld/videoVector/model/po/pgVector/ArticleQuality.java

@@ -0,0 +1,30 @@
+package com.tzld.videoVector.model.po.pgVector;
+
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+public class ArticleQuality {
+
+    private Long id;
+    private String contentId;
+
+    private Long totalRead;
+    private Double avgRead;
+    private Long totalFans;
+    private Integer publishCount;
+    private Double openRate;
+    private Double fissionRate;
+
+    private Double readScore;
+    private Double openScore;
+    private Double fissionScore;
+
+    private Double qualityScore;
+    private Double confidence;
+
+    private String dt;
+    private Date createTime;
+    private Date updateTime;
+}

+ 16 - 3
core/src/main/java/com/tzld/videoVector/model/vo/recall/RecallSignalsVO.java

@@ -27,9 +27,22 @@ public class RecallSignalsVO {
     @Data
     public static class QualitySignal {
         private boolean hasData;
-        private Double ctr;   // conversionEfficiencyScore
-        private Double viral; // viralScore
-        private Double roi;   // revenueScore
+        private Double ctr;
+        private Double viral;
+        private Double roi;
+
+        private Double readScore;
+        private Double openScore;
+        private Double fissionScore;
+
+        private Double confidence;
+
+        private Long totalRead;
+        private Double avgRead;
+        private Double readMultiplier;
+        private Double openRate;
+        private Double fissionRate;
+        private Integer publishCount;
     }
 
     @Data

+ 23 - 0
core/src/main/java/com/tzld/videoVector/service/rank/RankServiceImpl.java

@@ -106,6 +106,26 @@ public class RankServiceImpl implements RankService {
                 : params.getDeconstructBoost();
         if (codeBoost == null) codeBoost = params.getDeconstructBoost();
 
+        // ARTICLE 模态:优先用质量分(read/open/fission),无质量数据时退化为纯 sim
+        if (modality == Modality.ARTICLE) {
+            QualitySignal qs = signals.getQuality();
+            if (qs != null && qs.isHasData()
+                    && qs.getReadScore() != null && qs.getOpenScore() != null && qs.getFissionScore() != null) {
+                double qualTotalW = params.getWRead() + params.getWOpen() + params.getWFission();
+                if (qualTotalW <= 0) qualTotalW = 1;
+                double rawScore = (params.getWRead() * qs.getReadScore()
+                        + params.getWOpen() * qs.getOpenScore()
+                        + params.getWFission() * qs.getFissionScore()) / qualTotalW;
+                double confidence = qs.getConfidence() != null ? qs.getConfidence() : 1.0;
+                double qualityScore = confidence * rawScore + (1 - confidence) * 0.5;
+                double composite = params.getAlpha() * codeBoost * simNorm + (1 - params.getAlpha()) * qualityScore;
+                return ScoreBreakdown.of(composite, simNorm, 0, codeBoost, lowerBound, passesThreshold);
+            }
+            // 无质量数据 → 纯 sim
+            double composite = codeBoost * params.getAlpha() * simNorm;
+            return ScoreBreakdown.of(composite, simNorm, 0, codeBoost, lowerBound, passesThreshold);
+        }
+
         boolean hasRov = rov != null && Double.isFinite(rov);
 
         if (!hasRov) {
@@ -177,6 +197,9 @@ public class RankServiceImpl implements RankService {
         adaptiveParams.setWCtr(baseParams.getWCtr());
         adaptiveParams.setWViral(baseParams.getWViral());
         adaptiveParams.setWRoi(baseParams.getWRoi());
+        adaptiveParams.setWRead(baseParams.getWRead());
+        adaptiveParams.setWOpen(baseParams.getWOpen());
+        adaptiveParams.setWFission(baseParams.getWFission());
         adaptiveParams.setMaterialMissingStrategy(baseParams.getMaterialMissingStrategy());
 
         // 逐条打分 + 回填 rankScore

+ 9 - 0
core/src/main/java/com/tzld/videoVector/service/rank/RankingParams.java

@@ -64,6 +64,15 @@ public class RankingParams {
     private Double priorViral;
     private Double priorRoi;
 
+    /** 文章质量子维度权重——阅读,默认 0.4 */
+    private double wRead = 0.4;
+
+    /** 文章质量子维度权重——打开率,默认 0.3 */
+    private double wOpen = 0.3;
+
+    /** 文章质量子维度权重——裂变率,默认 0.3 */
+    private double wFission = 0.3;
+
     /**
      * 返回全局默认 RankingParams(与前端 DEFAULT_RANKING_PARAMS 一致)。
      */

+ 86 - 9
core/src/main/java/com/tzld/videoVector/service/recall/impl/VectorRecallTestServiceImpl.java

@@ -10,6 +10,7 @@ import com.tzld.videoVector.common.constant.VectorConstants;
 import com.tzld.videoVector.common.enums.Modality;
 import com.tzld.videoVector.dao.mapper.pgVector.DeconstructVectorConfigMapper;
 import com.tzld.videoVector.dao.mapper.pgVector.ext.ArticleDeconstructResultMapperExt;
+import com.tzld.videoVector.dao.mapper.pgVector.ext.ArticleQualityMapperExt;
 import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialDeconstructResultMapperExt;
 import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialQualityMapperExt;
 import com.tzld.videoVector.model.entity.ArticleMatch;
@@ -23,6 +24,7 @@ import com.tzld.videoVector.model.param.recall.MatchByMaterialIdParam;
 import com.tzld.videoVector.model.param.recall.MatchByTextParam;
 import com.tzld.videoVector.model.param.recall.MatchByVideoIdParam;
 import com.tzld.videoVector.model.po.pgVector.ArticleDeconstructResult;
+import com.tzld.videoVector.model.po.pgVector.ArticleQuality;
 import com.tzld.videoVector.model.po.pgVector.DeconstructVectorConfig;
 import com.tzld.videoVector.model.po.pgVector.DeconstructVectorConfigExample;
 import com.tzld.videoVector.model.po.pgVector.MaterialDeconstructResult;
@@ -112,6 +114,9 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
     @Autowired
     private ArticleDeconstructResultMapperExt articleDeconstructResultMapperExt;
 
+    @Autowired(required = false)
+    private ArticleQualityMapperExt articleQualityMapperExt;
+
     @Autowired
     private DeconstructVectorConfigMapper deconstructVectorConfigMapper;
 
@@ -287,7 +292,7 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
             articleItems = Collections.emptyList();
         }
 
-        List<VideoMatchEnrichedVO> videoItems = enrichVideoMatches(videoMatches, configCode);
+        List<VideoMatchEnrichedVO> videoItems = enrichVideoMatches(videoMatches, configCode, param.getDays());
 
         // WP3 召回前筛选:模态 + 来源(精排前先筛,减少无效候选进入 ranker)
         int beforeV = videoItems.size(), beforeM = materialItems.size(), beforeA = articleItems.size();
@@ -457,7 +462,7 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
                 .limit(enrichK).collect(Collectors.toList());
 
         // enrich
-        List<VideoMatchEnrichedVO> videoItems = enrichVideoMatches(topVideo, configCodes.get(0));
+        List<VideoMatchEnrichedVO> videoItems = enrichVideoMatches(topVideo, configCodes.get(0), param.getDays());
         for (int i = 0; i < videoItems.size() && i < topVideo.size(); i++) {
             String cc = topVideo.get(i).getConfigCode();
             if (cc != null) videoItems.get(i).setConfigCode(cc);
@@ -797,7 +802,8 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
     /**
      * 视频召回结果 enrich
      */
-    private List<VideoMatchEnrichedVO> enrichVideoMatches(List<VideoMatchResult> matches, String requestConfigCode) {
+    private List<VideoMatchEnrichedVO> enrichVideoMatches(List<VideoMatchResult> matches, String requestConfigCode,
+                                                           Integer days) {
         if (CollectionUtils.isEmpty(matches)) {
             return Collections.emptyList();
         }
@@ -811,7 +817,7 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
                 : videoApiService.getVideoDetail(videoIds);
 
         // 从 Redis 加载视频详情(含 ROV 等指标数据)用于精排打分
-        Map<Long, Map<String, Object>> redisDetails = loadRedisVideoDetails(matches);
+        Map<Long, Map<String, Object>> redisDetails = loadRedisVideoDetails(matches, days);
 
         List<VideoMatchEnrichedVO> items = new ArrayList<>(matches.size());
         for (VideoMatchResult m : matches) {
@@ -853,16 +859,18 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
      * 从 Redis 批量加载视频详情(key: video:detail:{metricsDays}d:{videoId})
      * 用于提取 ROV 等指标数据做精排打分。
      */
-    private Map<Long, Map<String, Object>> loadRedisVideoDetails(List<VideoMatchResult> matches) {
+    private Map<Long, Map<String, Object>> loadRedisVideoDetails(List<VideoMatchResult> matches, Integer days) {
         Map<Long, Map<String, Object>> result = new HashMap<>();
         if (CollectionUtils.isEmpty(matches)) return result;
 
+        int effectiveDays = days != null ? days : metricsDays;
+
         List<Long> orderedIds = new ArrayList<>();
         List<String> keys = new ArrayList<>();
         for (VideoMatchResult m : matches) {
             if (m != null && m.getVideoId() != null) {
                 orderedIds.add(m.getVideoId());
-                keys.add(VectorConstants.VIDEO_DETAIL_DAYS_KEY_PREFIX + metricsDays + "d:" + m.getVideoId());
+                keys.add(VectorConstants.VIDEO_DETAIL_DAYS_KEY_PREFIX + effectiveDays + "d:" + m.getVideoId());
             }
         }
         if (keys.isEmpty()) return result;
@@ -1082,6 +1090,7 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
                 .filter(java.util.Objects::nonNull)
                 .collect(Collectors.toList());
         Map<String, ArticleDeconstructResult> rowByArticleId = loadArticleDeconstructRows(articleIds);
+        Map<String, ArticleQuality> qualityByArticleId = loadArticleQualityRows(articleIds);
 
         List<VideoMatchEnrichedVO> items = new ArrayList<>(matches.size());
         for (ArticleMatch m : matches) {
@@ -1125,6 +1134,27 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
 
             applyCompatibilityFields(vo);
             applySignals(vo, requestConfigCode, "ann");
+
+            // 文章质量分 enrich
+            ArticleQuality aq = qualityByArticleId.get(m.getArticleId());
+            if (aq != null && vo.getSignals() != null) {
+                RecallSignalsVO.QualitySignal qs = new RecallSignalsVO.QualitySignal();
+                qs.setHasData(true);
+                qs.setReadScore(aq.getReadScore());
+                qs.setOpenScore(aq.getOpenScore());
+                qs.setFissionScore(aq.getFissionScore());
+                qs.setConfidence(aq.getConfidence());
+                qs.setTotalRead(aq.getTotalRead());
+                qs.setAvgRead(aq.getAvgRead());
+                if (aq.getAvgRead() != null && aq.getAvgRead() > 0) {
+                    qs.setReadMultiplier((double) aq.getTotalRead() / aq.getAvgRead());
+                }
+                qs.setOpenRate(aq.getOpenRate());
+                qs.setFissionRate(aq.getFissionRate());
+                qs.setPublishCount(aq.getPublishCount());
+                vo.getSignals().setQuality(qs);
+            }
+
             items.add(vo);
         }
         return items;
@@ -1153,6 +1183,31 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         return result;
     }
 
+    private Map<String, ArticleQuality> loadArticleQualityRows(List<String> articleIds) {
+        if (CollectionUtils.isEmpty(articleIds)) {
+            return Collections.emptyMap();
+        }
+        if (articleQualityMapperExt == null) {
+            return Collections.emptyMap();
+        }
+        Map<String, ArticleQuality> result = new HashMap<>();
+        try {
+            List<ArticleQuality> rows = articleQualityMapperExt.selectByContentIds(articleIds);
+            if (CollectionUtils.isEmpty(rows)) {
+                return result;
+            }
+            for (ArticleQuality row : rows) {
+                if (row == null || !StringUtils.hasText(row.getContentId())) {
+                    continue;
+                }
+                result.putIfAbsent(row.getContentId(), row);
+            }
+        } catch (Exception e) {
+            log.error("批量加载 article_quality 失败: {}", e.getMessage(), e);
+        }
+        return result;
+    }
+
     private JSONObject parseArticleResultJson(ArticleDeconstructResult row) {
         if (row == null || !StringUtils.hasText(row.getResult())) {
             return null;
@@ -1657,6 +1712,9 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         if (spec.getWViral() != null) p.setWViral(spec.getWViral());
         if (spec.getWRoi() != null) p.setWRoi(spec.getWRoi());
         if (spec.getMaterialMissingStrategy() != null) p.setMaterialMissingStrategy(spec.getMaterialMissingStrategy());
+        if (spec.getWRead() != null) p.setWRead(spec.getWRead());
+        if (spec.getWOpen() != null) p.setWOpen(spec.getWOpen());
+        if (spec.getWFission() != null) p.setWFission(spec.getWFission());
         return p;
     }
 
@@ -1824,7 +1882,7 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         }
         String configCode = StringUtils.hasText(param.getConfigCode())
                 ? param.getConfigCode() : VectorConstants.DEFAULT_CONFIG_CODE;
-        List<VideoMatchEnrichedVO> videoItems = enrichVideoMatches(rawMatches, configCode);
+        List<VideoMatchEnrichedVO> videoItems = enrichVideoMatches(rawMatches, configCode, null);
         // displayK 截断
         if (videoItems.size() > displayK) {
             videoItems = limitEnrichedItemsByScore(videoItems, displayK);
@@ -1900,7 +1958,7 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
                     if (!deduped.isEmpty()) {
                         List<VideoMatchResult> videoResults = toVideoMatchResults(deduped, cc);
                         populateVideoMatchResultDetails(videoResults);
-                        allResults.addAll(enrichVideoMatches(videoResults, cc));
+                        allResults.addAll(enrichVideoMatches(videoResults, cc, null));
                     }
                 } catch (Exception e) {
                     log.error("matchByMaterialId 视频搜索失败 configCode={}: {}", cc, e.getMessage(), e);
@@ -2293,7 +2351,7 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
                     if (!deduped.isEmpty()) {
                         List<VideoMatchResult> videoResults = toVideoMatchResults(deduped, cc);
                         populateVideoMatchResultDetails(videoResults);
-                        allResults.addAll(enrichVideoMatches(videoResults, cc));
+                        allResults.addAll(enrichVideoMatches(videoResults, cc, null));
                     }
                 } catch (Exception e) {
                     log.error("matchByArticleId 视频搜索失败 configCode={}: {}", cc, e.getMessage(), e);
@@ -2422,6 +2480,25 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
 
         applyCompatibilityFields(vo);
         applySignals(vo, configCode, "self");
+
+        // 文章质量分 enrich
+        Map<String, ArticleQuality> selfQualityMap = loadArticleQualityRows(Collections.singletonList(articleId));
+        ArticleQuality aq = selfQualityMap.get(articleId);
+        if (aq != null && vo.getSignals() != null) {
+            RecallSignalsVO.QualitySignal qs = new RecallSignalsVO.QualitySignal();
+            qs.setHasData(true);
+            qs.setReadScore(aq.getReadScore());
+            qs.setOpenScore(aq.getOpenScore());
+            qs.setFissionScore(aq.getFissionScore());
+            qs.setConfidence(aq.getConfidence());
+            qs.setTotalRead(aq.getTotalRead());
+            qs.setAvgRead(aq.getAvgRead());
+            qs.setOpenRate(aq.getOpenRate());
+            qs.setFissionRate(aq.getFissionRate());
+            qs.setPublishCount(aq.getPublishCount());
+            vo.getSignals().setQuality(qs);
+        }
+
         return vo;
     }
 

+ 137 - 0
core/src/main/java/com/tzld/videoVector/util/ArticleQualityCalculator.java

@@ -0,0 +1,137 @@
+package com.tzld.videoVector.util;
+
+import com.tzld.videoVector.model.po.pgVector.ArticleQuality;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class ArticleQualityCalculator {
+
+    private static final Logger log = LoggerFactory.getLogger(ArticleQualityCalculator.class);
+
+    private static final double DEFAULT_PRIOR = 0.5;
+
+    public static void calculateAll(List<ArticleQuality> list,
+                                    double wRead, double wOpen, double wFission,
+                                    int confidenceThreshold) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+
+        double qualTotalW = wRead + wOpen + wFission;
+        if (qualTotalW <= 0) qualTotalW = 1;
+
+        int totalCount = list.size();
+        log.info("开始计算文章质量分, 总数: {}, 权重: r={} o={} f={}, 置信度阈值: {}",
+                totalCount, wRead, wOpen, wFission, confidenceThreshold);
+
+        // 提取原始维度值
+        List<DimValues> dimValuesList = new ArrayList<>(totalCount);
+        for (ArticleQuality aq : list) {
+            DimValues dv = new DimValues();
+            dv.totalRead = nullToZero(aq.getTotalRead());
+            double avgRead = nullToZero(aq.getAvgRead());
+            // 阅读均值倍数 = 总阅读 / 总阅读均值(avgRead = 单次发文平均阅读累加)
+            dv.readMultiplier = avgRead > 0 ? dv.totalRead / avgRead : 0;
+            dv.openRate = nullToZero(aq.getOpenRate());
+            dv.fissionRate = nullToZero(aq.getFissionRate());
+            dv.publishCount = aq.getPublishCount() != null ? aq.getPublishCount() : 0;
+            dimValuesList.add(dv);
+        }
+
+        // readScore 基于阅读均值倍数(总阅读 / 总阅读均值),而非 totalRead
+        computePercentileRanks(dimValuesList, dv -> dv.readMultiplier, (dv, r) -> dv.readPct = r);
+        computePercentileRanks(dimValuesList, dv -> dv.openRate, (dv, r) -> dv.openPct = r);
+        computePercentileRanks(dimValuesList, dv -> dv.fissionRate, (dv, r) -> dv.fissionPct = r);
+
+        int lowConfCount = 0;
+        int noDataCount = 0;
+
+        for (int i = 0; i < list.size(); i++) {
+            ArticleQuality aq = list.get(i);
+            DimValues dv = dimValuesList.get(i);
+
+            double confidence = dv.publishCount >= confidenceThreshold
+                    ? 1.0
+                    : (double) dv.publishCount / confidenceThreshold;
+
+            if (dv.publishCount <= 0) {
+                noDataCount++;
+            } else if (confidence < 1.0) {
+                lowConfCount++;
+            }
+
+            double rawScore = (wRead * dv.readPct + wOpen * dv.openPct
+                    + wFission * dv.fissionPct) / qualTotalW;
+            double qualityScore = confidence * rawScore + (1 - confidence) * DEFAULT_PRIOR;
+
+            aq.setReadScore(round2(dv.readPct));
+            aq.setOpenScore(round2(dv.openPct));
+            aq.setFissionScore(round2(dv.fissionPct));
+            aq.setQualityScore(round2(qualityScore));
+            aq.setConfidence(round2(confidence));
+        }
+
+        log.info("文章质量分计算完成, 总数: {}, 无发文: {}, 低于置信度阈值: {}",
+                totalCount, noDataCount, lowConfCount);
+    }
+
+
+    @FunctionalInterface
+    private interface ValueExtractor {
+        double extract(DimValues dv);
+    }
+
+    @FunctionalInterface
+    private interface RankSetter {
+        void set(DimValues dv, double rank);
+    }
+
+    private static void computePercentileRanks(List<DimValues> list,
+                                               ValueExtractor getter,
+                                               RankSetter setter) {
+        int n = list.size();
+        List<DimValues> sorted = list.stream()
+                .sorted(Comparator.comparingDouble(getter::extract))
+                .collect(Collectors.toList());
+
+        for (int i = 0; i < n; ) {
+            double val = getter.extract(sorted.get(i));
+            int j = i;
+            while (j < n && Double.compare(getter.extract(sorted.get(j)), val) == 0) {
+                j++;
+            }
+            double avgRank = (i + j - 1) / 2.0;
+            double pct = (n > 1) ? avgRank / (n - 1) : 0.5;
+            for (int k = i; k < j; k++) {
+                setter.set(sorted.get(k), pct);
+            }
+            i = j;
+        }
+    }
+
+
+    private static double nullToZero(Long v) { return v == null ? 0 : (double) v; }
+    private static double nullToZero(Double v) { return v == null ? 0 : v; }
+
+    public static double round2(Double v) {
+        if (v == null) return 0;
+        return Math.round(v * 100.0) / 100.0;
+    }
+
+    private static class DimValues {
+        double totalRead;
+        double readMultiplier;
+        double openRate;
+        double fissionRate;
+        int publishCount;
+
+        double readPct = 0.5;
+        double openPct = 0.5;
+        double fissionPct = 0.5;
+    }
+}

+ 71 - 0
core/src/main/resources/mapper/pgVector/ext/ArticleQualityMapperExt.xml

@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.tzld.videoVector.dao.mapper.pgVector.ext.ArticleQualityMapperExt">
+
+    <resultMap id="BaseResultMap" type="com.tzld.videoVector.model.po.pgVector.ArticleQuality">
+        <id column="id" property="id"/>
+        <result column="content_id" property="contentId"/>
+        <result column="total_read" property="totalRead"/>
+        <result column="avg_read" property="avgRead"/>
+        <result column="total_fans" property="totalFans"/>
+        <result column="publish_count" property="publishCount"/>
+        <result column="open_rate" property="openRate"/>
+        <result column="fission_rate" property="fissionRate"/>
+        <result column="read_score" property="readScore"/>
+        <result column="open_score" property="openScore"/>
+        <result column="fission_score" property="fissionScore"/>
+        <result column="quality_score" property="qualityScore"/>
+        <result column="confidence" property="confidence"/>
+        <result column="dt" property="dt"/>
+        <result column="create_time" property="createTime"/>
+        <result column="update_time" property="updateTime"/>
+    </resultMap>
+
+    <insert id="batchUpsert">
+        INSERT INTO article_quality (
+            content_id,
+            total_read, avg_read, total_fans, publish_count,
+            open_rate, fission_rate,
+            read_score, open_score, fission_score,
+            quality_score, confidence,
+            dt, create_time, update_time
+        )
+        VALUES
+        <foreach collection="list" item="item" separator=",">
+        (
+            #{item.contentId},
+            #{item.totalRead}, #{item.avgRead}, #{item.totalFans}, #{item.publishCount},
+            #{item.openRate}, #{item.fissionRate},
+            #{item.readScore}, #{item.openScore}, #{item.fissionScore},
+            #{item.qualityScore}, #{item.confidence},
+            #{item.dt}, NOW(), NOW()
+        )
+        </foreach>
+        ON CONFLICT (content_id, dt)
+        DO UPDATE SET
+            total_read = EXCLUDED.total_read,
+            avg_read = EXCLUDED.avg_read,
+            total_fans = EXCLUDED.total_fans,
+            publish_count = EXCLUDED.publish_count,
+            open_rate = EXCLUDED.open_rate,
+            fission_rate = EXCLUDED.fission_rate,
+            read_score = EXCLUDED.read_score,
+            open_score = EXCLUDED.open_score,
+            fission_score = EXCLUDED.fission_score,
+            quality_score = EXCLUDED.quality_score,
+            confidence = EXCLUDED.confidence,
+            dt = EXCLUDED.dt,
+            update_time = NOW()
+    </insert>
+
+    <select id="selectByContentIds" resultMap="BaseResultMap">
+        SELECT DISTINCT ON (content_id) *
+        FROM article_quality
+        WHERE content_id IN
+        <foreach collection="contentIds" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+        ORDER BY content_id, dt DESC
+    </select>
+
+</mapper>