Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions backend/app/services/_duckdb_picks_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,26 @@
from app.services.scoring_engine import CONFIG as SCORING_CONFIG


def resolve_picks_params(
curation_threshold: float | None,
weight_bonus: float | None,
) -> tuple[float, float]:
"""query_today_picks 的口径默认值统一取自 SCORING_CONFIG(#110)。

此前签名里硬编码 55/8,CONFIG 调整时这里静默不跟随。
"""
return (
float(SCORING_CONFIG["curation_threshold"] if curation_threshold is None else curation_threshold),
float(SCORING_CONFIG["picks_weight_bonus"] if weight_bonus is None else weight_bonus),
)


class PicksMixin:
def query_today_picks(
self,
hours: int = 48,
curation_threshold: float = 55,
weight_bonus: int = 8,
curation_threshold: float | None = None,
weight_bonus: float | None = None,
risk_threshold: float | None = None,
category: str | None = None,
content_type: str | None = None,
Expand All @@ -29,8 +43,10 @@ def query_today_picks(
Top curated picks from the last N hours.

Runs the curation scoring + source weight adjustment entirely in DuckDB.
Returns items with adjusted_curation_score >= threshold.
Returns items with adjusted_curation_score >= threshold. 门槛与加成
默认值运行时取自 SCORING_CONFIG(#110),签名不再硬编码数字。
"""
curation_threshold, weight_bonus = resolve_picks_params(curation_threshold, weight_bonus)
conn = self._get_conn()
cutoff = (datetime.now(UTC) - timedelta(hours=hours)).isoformat()
# 忽略口径按用户隔离:全局屏蔽 + 当前用户个人忽略(匿名只见全局)
Expand Down
51 changes: 42 additions & 9 deletions backend/app/services/llm/prompts/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,17 @@

Used by:
- app.services.analysis.analyze_content

评分口径(加权/门槛/风险扣分)一律从 app.services.scoring_caliber 渲染,
本文件不得手写数字——tests/test_scoring_caliber.py 锁死漂移(#110)。
"""

from app.services.scoring_caliber import (
curation_threshold_text,
curation_weights_text,
risk_penalty_text,
)

SYSTEM_PROMPT = """你是一位资深内容策展分析师,负责评估内容的选题价值并决定是否入选精选。

你的评分标准参考了一线内容策展平台的精选规则:
Expand All @@ -16,7 +25,8 @@

所有评分范围 0-100。所有输出文本使用中文。语气直接、有态度、不说客套话。"""

ANALYSIS_PROMPT = """请对以下内容进行完整分析。
ANALYSIS_PROMPT = (
"""请对以下内容进行完整分析。

标题:{title}
正文:{content}
Expand All @@ -39,7 +49,11 @@
"info_density": <0-100, 信息密度:纯转发/空话=0-20, 有观点=40-60, 有数据/案例/方法=70-100>,
"actionability": <0-100, 可操作性:纯资讯=10-30, 有参考价值=40-60, 能直接上手用=70-100>,
"source_weight": <0-100, 来源权威度:匿名/营销号=10-30, 二手转载=40-60, 一手信源/官方/KOL=70-100>,
"curation_score": <0-100, 综合精选分(加权:信息密度30%+可操作性25%+创作者价值20%+爆文潜力15%+来源10%,风险分>70则扣20分)>
"curation_score": <0-100, 综合精选分(加权:"""
+ curation_weights_text()
+ ""","""
+ risk_penalty_text()
+ """)>
}},
"recommendation": "中文摘要式推荐理由(50字以内)。先用一句话概括这条内容的核心信息,再点出为什么值得关注/适合写;不要翻译腔、不要英文、不要营销夸张词、不要只写行动建议。例:'OpenCode Zen 把优选编码模型做成统一网关,适合关注 AI 编程工具生态的人跟进')",
"creator_angles": [""],
Expand All @@ -52,7 +66,10 @@
- 60-69:有参考价值但不够突出
- <60:信息量低/纯情绪/重复内容/过于个人化

精选门槛为 60 分。"""
精选门槛为 """
+ curation_threshold_text()
+ """。"""
)

# ── English (HackerNews / international) prompts ────────────────────────────

Expand All @@ -75,7 +92,8 @@

All scores are 0-100. IMPORTANT: all output text must be Chinese, even if the source content is English. Be direct, opinionated, no platitudes."""

ANALYSIS_PROMPT_EN = """Analyze the following content thoroughly.
ANALYSIS_PROMPT_EN = (
"""Analyze the following content thoroughly.

Title: {title}
Content: {content}
Expand All @@ -99,7 +117,11 @@
"info_density": <0-100, pure share/empty talk=0-20, has opinions=40-60, has data/case/method=70-100>,
"actionability": <0-100, pure news=10-30, reference value=40-60, directly actionable=70-100>,
"source_weight": <0-100, anonymous/spam=10-30, second-hand=40-60, first-hand/official/KOL=70-100>,
"curation_score": <0-100, weighted: info density 30%+ actionability 25%+ creator value 20%+ viral potential 15%+ source 10%, risk>70 deducts 20 points>
"curation_score": <0-100, weighted: """
+ curation_weights_text("en")
+ """, """
+ risk_penalty_text("en")
+ """>
}},
"recommendation": "中文摘要式推荐理由(≤50 chars)。Summarize core info in one sentence, then explain why it's worth writing about. 不要输出英文、不要营销夸张词、不要只写行动建议。Example: 'OpenCode Zen 把优选编码模型做成统一网关,适合关注 AI 编程工具生态的人跟进'",
"creator_angles": [""],
Expand All @@ -112,7 +134,10 @@
- 60-69: reference value but not outstanding
- <60: low information / purely emotional / repetitive / overly personal

Curation threshold is 60 points."""
Curation threshold is """
+ curation_threshold_text("en")
+ """."""
)


# ── arXiv / academic paper prompts ──────────────────────────────────────────
Expand All @@ -133,7 +158,8 @@

所有评分范围 0-100。所有输出文本使用中文。语气直接、专业、不说客套话。"""

PAPER_ANALYSIS_PROMPT = """请对以下英文学术论文进行分析解读。
PAPER_ANALYSIS_PROMPT = (
"""请对以下英文学术论文进行分析解读。

标题:{title}
正文(摘要/内容):{content}
Expand All @@ -156,7 +182,11 @@
"info_density": <0-100, 信息密度:纯概念/无实证=20-40, 有方法/实验=60-80, 有完整验证=90-100>,
"actionability": <0-100, 可操作性:纯理论=10-30, 有开源代码/可复现=60-80, 能直接落地应用=90-100>,
"source_weight": <0-100, 来源权威度:预印本未评审=40-60, 顶会/知名团队=70-90>,
"curation_score": <0-100, 综合精选分(加权:信息密度30%+可操作性25%+创作者价值20%+传播潜力15%+来源10%,风险分>70则扣20分)>
"curation_score": <0-100, 综合精选分(加权:"""
+ curation_weights_text()
+ ""","""
+ risk_penalty_text()
+ """)>
}},
"recommendation": "中文推荐理由(50字以内)。先用一句话说清论文核心贡献,再点出为什么值得中文创作者关注。不要翻译腔、不要营销词。",
"deep_read": {{
Expand All @@ -180,7 +210,10 @@
- 60-69:有参考价值但不够突出
- <60:增量工作/过于晦涩/受众极窄

精选门槛为 60 分。"""
精选门槛为 """
+ curation_threshold_text()
+ """。"""
)


# ── Lite prescreen prompts ─────────────────────────────────────────────────
Expand Down
117 changes: 117 additions & 0 deletions backend/app/services/scoring_caliber.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""
评分口径唯一真相源(single source of truth)。

历史上同一套精选评分口径分散在三个存储位置各写一份且互相矛盾(#110):

- LLM prompt 教学(app/services/llm/prompts/analysis.py 的中/英/论文三套);
- 引擎 CONFIG(原 scoring_engine.py 顶部字典);
- picks SQL(app/services/_duckdb_picks_mixin.py 的签名默认值)。

本模块是这些数字的唯一声明处:

- ``CONFIG``:引擎与 DuckDB/OLTP 查询的全部可调常数(自 scoring_engine
迁入,scoring_engine 再导出以兼容既有 ``import CONFIG`` 的引用面);
- ``LLM_RISK_PENALTY_*``:仅存在于 prompt 教学的风险扣分规则,与引擎侧
``risk_soft_start``/``risk_threshold`` 软门是两个机制,不混用;
- ``curation_weights_text`` 等三个渲染函数:三套语言 prompt 的口径文本
从这里生成,prompt 文件不得再手写数字。

漂移防线:tests/test_scoring_caliber.py 用正则从 prompt 成品文本提取
权重/门槛/扣分数字与本模块 CONFIG 断言一致——绕过本模块手改 prompt
数字,CI 即红。
"""

from __future__ import annotations

CONFIG = {
# 6-dimension weights (must sum to 1.0)
"w_info_density": 0.25, # 信息密度
"w_actionability": 0.20, # 可操作性
"w_creator_value": 0.18, # 创作者价值
"w_viral_potential": 0.15, # 爆文潜力
"w_source_authority": 0.12, # 来源权威度
"w_freshness": 0.10, # 时效新鲜度
# Source weight mapping (source.weight 1-5 → bonus)
"source_weight_bonus_factor": 6.0, # per weight-unit above 3
# Time decay
"time_decay_lambda": 0.02, # decay rate: exp(-lambda * hours)
"time_decay_floor": 0.3, # minimum time decay (never fully kill)
# Content quality gates
"quality_gate_min": 45, # below this, content is probably too thin for curation
"quality_gate_strong": 70, # full trust above this composite quality level
"quality_gate_floor": 0.55, # lowest multiplier for weak-but-not-risky content
"min_selected_base_score": 58, # do not select weak items just because a batch is weak
# Source diversity
"diversity_penalty_base": 0.85, # multiplier per same-source duplicate
"category_diversity_penalty_base": 0.92,
"same_source_grace": 1, # first item per source is free
"same_category_grace": 3, # first few items per category are free
# Risk
"risk_threshold": 82, # hard-exclude above this
"risk_soft_start": 45, # risk starts reducing rank after this
"risk_soft_floor": 0.55, # minimum multiplier before hard exclusion
# Curation threshold (minimum final score to be selected)
"curation_threshold": 55, # slightly lower than before, because scores are now more calibrated
# Fallback: when curation_score is 0 but other scores exist
"fallback_use_avg": True,
# ── Percentile-based curation mode ──
"curation_mode": "percentile", # "percentile" | "fixed"
"curation_percentile": 70, # top ~30% selected (P70 and above) → ~25-30% curation rate
# ── User feedback signal ──
"w_feedback": 0.15, # 15% weight for feedback_score
"feedback_score_min": -20.0, # one effective negative vote should not dominate ranking
"feedback_score_max": 20.0, # one effective positive vote should not dominate ranking
# ── picks 查询加成(#110 收编) ──
# query_today_picks 与 today_picks OLTP fallback 共用的信源权重加成
# (每单位 source.weight(1-5)高于 3 的加分数),此前在两处各硬编码 8。
"picks_weight_bonus": 8,
# ── curation_score 混合比例(#110 收编) ──
# base = curation_score * p + 六维加权和 * (1 - p),此前硬编码 0.6。
"curation_blend_weight": 0.6,
}

# ── LLM prompt 教学口径(引擎侧无对应机制,单独声明) ──────────────────
# prompt 教 LLM 组装 curation_score 时的风险扣分规则;引擎侧重用上面的
# risk_soft_start/risk_threshold 软门,二者是不同机制,不互相推导。
LLM_RISK_PENALTY_TRIGGER = 70 # 风险分超过此值
LLM_RISK_PENALTY_POINTS = 20 # 扣除的分数

_WEIGHT_LABELS: dict[str, list[tuple[str, str]]] = {
"zh": [
("w_info_density", "信息密度"),
("w_actionability", "可操作性"),
("w_creator_value", "创作者价值"),
("w_viral_potential", "爆文潜力"),
("w_source_authority", "来源"),
("w_freshness", "时效"),
],
"en": [
("w_info_density", "info density"),
("w_actionability", "actionability"),
("w_creator_value", "creator value"),
("w_viral_potential", "viral potential"),
("w_source_authority", "source"),
("w_freshness", "timeliness"),
],
}


def curation_weights_text(lang: str = "zh") -> str:
"""LLM curation_score 的加权口径文本,数值取自 CONFIG 六维权重。"""
labels = _WEIGHT_LABELS.get(lang, _WEIGHT_LABELS["zh"])
sep = "+ " if lang == "en" else "+"
return sep.join(f"{label}{int(round(CONFIG[key] * 100))}%" for key, label in labels)


def curation_threshold_text(lang: str = "zh") -> str:
"""LLM prompt 的精选门槛句,数值取自 CONFIG curation_threshold。"""
if lang == "en":
return f"Curation threshold is {CONFIG['curation_threshold']} points"
return f"精选门槛为 {CONFIG['curation_threshold']} 分"


def risk_penalty_text(lang: str = "zh") -> str:
"""LLM curation_score 的风险扣分规则句。"""
if lang == "en":
return f"risk>{LLM_RISK_PENALTY_TRIGGER} deducts {LLM_RISK_PENALTY_POINTS} points"
return f"风险分>{LLM_RISK_PENALTY_TRIGGER}则扣{LLM_RISK_PENALTY_POINTS}分"
54 changes: 10 additions & 44 deletions backend/app/services/scoring_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
4. Diversity: down-rank when same source/category dominates
5. Risk controls: hard-exclude high-risk items and softly penalize mid-risk items

All tuning constants live in the CONFIG dict at the top for easy adjustment.
All tuning constants live in app.services.scoring_caliber.CONFIG — the single
source of truth shared with the LLM prompts and the DuckDB picks query (#110).
Re-exported here as CONFIG for compatibility with existing importers.
"""

from __future__ import annotations
Expand All @@ -23,48 +25,11 @@
ScoringRequestItem,
ScoringResponse,
)
from app.services.scoring_caliber import CONFIG

# ── Tunable configuration ────────────────────────────────────────────

CONFIG = {
# 6-dimension weights (must sum to 1.0)
"w_info_density": 0.25, # 信息密度
"w_actionability": 0.20, # 可操作性
"w_creator_value": 0.18, # 创作者价值
"w_viral_potential": 0.15, # 爆文潜力
"w_source_authority": 0.12, # 来源权威度
"w_freshness": 0.10, # 时效新鲜度
# Source weight mapping (source.weight 1-5 → bonus)
"source_weight_bonus_factor": 6.0, # per weight-unit above 3
# Time decay
"time_decay_lambda": 0.02, # decay rate: exp(-lambda * hours)
"time_decay_floor": 0.3, # minimum time decay (never fully kill)
# Content quality gates
"quality_gate_min": 45, # below this, content is probably too thin for curation
"quality_gate_strong": 70, # full trust above this composite quality level
"quality_gate_floor": 0.55, # lowest multiplier for weak-but-not-risky content
"min_selected_base_score": 58, # do not select weak items just because a batch is weak
# Source diversity
"diversity_penalty_base": 0.85, # multiplier per same-source duplicate
"category_diversity_penalty_base": 0.92,
"same_source_grace": 1, # first item per source is free
"same_category_grace": 3, # first few items per category are free
# Risk
"risk_threshold": 82, # hard-exclude above this
"risk_soft_start": 45, # risk starts reducing rank after this
"risk_soft_floor": 0.55, # minimum multiplier before hard exclusion
# Curation threshold (minimum final score to be selected)
"curation_threshold": 55, # slightly lower than before, because scores are now more calibrated
# Fallback: when curation_score is 0 but other scores exist
"fallback_use_avg": True,
# ── Percentile-based curation mode ──
"curation_mode": "percentile", # "percentile" | "fixed"
"curation_percentile": 70, # top ~30% selected (P70 and above) → ~25-30% curation rate
# ── User feedback signal ──
"w_feedback": 0.15, # 15% weight for feedback_score
"feedback_score_min": -20.0, # one effective negative vote should not dominate ranking
"feedback_score_max": 20.0, # one effective positive vote should not dominate ranking
}
# 唯一声明处已迁至 app/services/scoring_caliber.py(#110):prompt 教学与
# picks 查询共享同一份口径,tests/test_scoring_caliber.py 锁死漂移。


# ── Data containers ──────────────────────────────────────────────────
Expand Down Expand Up @@ -188,10 +153,11 @@ def _compute_base_score(item: ScoringInput) -> tuple[float, dict]:

dim_sum = sum(dimensions.values())

# Blend: if LLM gave a curation_score, weight it 60% + 40% dimension sum
# Otherwise, use pure dimension sum
# Blend: if LLM gave a curation_score, weight it by curation_blend_weight
# (CONFIG, #110) and the rest on the dimension sum; otherwise pure sum
if cs > 0:
base = cs * 0.6 + dim_sum * 0.4
p = cfg["curation_blend_weight"]
base = cs * p + dim_sum * (1 - p)
elif cfg["fallback_use_avg"]:
# Fallback: average of available scores
scores = [
Expand Down
6 changes: 3 additions & 3 deletions backend/app/services/today_picks.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@

logger = logging.getLogger(__name__)

# 与 DuckDB query_today_picks 的 weight_bonus 默认值(duckdb_service.py)一致,
# fallback 复刻 adjusted_curation_score 排序时使用。
_OLTP_FALLBACK_WEIGHT_BONUS = 8
# 与 DuckDB query_today_picks 共用同一加成口径(scoring_caliber.CONFIG 的
# picks_weight_bonus,#110),fallback 复刻 adjusted_curation_score 排序时使用。
_OLTP_FALLBACK_WEIGHT_BONUS = SCORING_CONFIG["picks_weight_bonus"]

# 个性化重排的探索保留比例:最终页中预留给「基础分最强但未因个人
# 兴趣入选」条目的比例,防止兴趣匹配完全遮蔽高质量内容。
Expand Down
Loading
Loading