优化推荐策略为多维度|更新推荐策略相关参数,支持使用url自行覆盖调节|增加全局短时间防重复系统|更新docs文档

This commit is contained in:
inliver233 committed 2026-02-25 23:15:41 +08:00
1 parent 1f37e8ec02
commit 565946a2b0
10 files changed
+597 -34

No files matched your search

+120
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import math
from typing import Any
from fastapi import APIRouter, Depends, Request
@@ -34,6 +35,8 @@ _DEFAULT_RECOMMENDATION: dict[str, Any] = {
"pick_mode": "weighted",
"temperature": 1.0,
"score_weights": dict(_DEFAULT_SCORE_WEIGHTS),
"freshness_half_life_days": 21.0,
"velocity_smooth_days": 2.0,
"multipliers": {
"ai": 1.0,
"non_ai": 1.0,
@@ -52,6 +55,15 @@ _DEFAULT_SETTINGS = {
"fail_cooldown_ms": 600_000,
"strategy": "quality",
"quality_samples": 12,
"dedup": {
"enabled": True,
"window_s": 20 * 60,
"max_images": 5000,
"max_authors": 2000,
"strict": False,
"image_penalty": 8.0,
"author_penalty": 2.5,
},
"recommendation": dict(_DEFAULT_RECOMMENDATION),
},
"proxy": {"allowlist_domains": []},
@@ -99,6 +111,79 @@ def _as_float(value: Any) -> float | None:
return None
def _normalize_dedup(value: Any, *, strict: bool) -> dict[str, Any]:
default = dict(_DEFAULT_SETTINGS["random"]["dedup"])
if value is None:
return default
if not isinstance(value, dict):
if strict:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid random.dedup", status_code=400)
return default
if strict:
for k in value.keys():
if str(k) not in default:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid random.dedup", status_code=400)
enabled = default["enabled"]
v = _as_bool(value.get("enabled"))
if v is not None:
enabled = bool(v)
window_s = int(default["window_s"])
if "window_s" in value:
try:
n = int(value.get("window_s"))
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid random.dedup.window_s", status_code=400) from exc
window_s = int(max(0, min(n, 24 * 60 * 60)))
max_images = int(default["max_images"])
if "max_images" in value:
try:
n = int(value.get("max_images"))
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid random.dedup.max_images", status_code=400) from exc
max_images = int(max(1, min(n, 200_000)))
max_authors = int(default["max_authors"])
if "max_authors" in value:
try:
n = int(value.get("max_authors"))
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid random.dedup.max_authors", status_code=400) from exc
max_authors = int(max(1, min(n, 200_000)))
strict_mode = bool(default["strict"])
v = _as_bool(value.get("strict"))
if v is not None:
strict_mode = bool(v)
image_penalty = float(default["image_penalty"])
if "image_penalty" in value:
v = _as_float(value.get("image_penalty"))
if v is None or not math.isfinite(float(v)):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid random.dedup.image_penalty", status_code=400)
image_penalty = float(max(0.0, min(float(v), 1000.0)))
author_penalty = float(default["author_penalty"])
if "author_penalty" in value:
v = _as_float(value.get("author_penalty"))
if v is None or not math.isfinite(float(v)):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid random.dedup.author_penalty", status_code=400)
author_penalty = float(max(0.0, min(float(v), 1000.0)))
return {
"enabled": bool(enabled),
"window_s": int(window_s),
"max_images": int(max_images),
"max_authors": int(max_authors),
"strict": bool(strict_mode),
"image_penalty": float(image_penalty),
"author_penalty": float(author_penalty),
}
def _normalize_recommendation(value: Any, *, strict: bool) -> dict[str, Any]:
if value is None:
return dict(_DEFAULT_RECOMMENDATION)
@@ -132,6 +217,34 @@ def _normalize_recommendation(value: Any, *, strict: bool) -> dict[str, Any]:
else:
temperature = float(max(0.05, min(float(v), 100.0)))
freshness_half_life_default = float(_DEFAULT_RECOMMENDATION["freshness_half_life_days"])
freshness_half_life_days = freshness_half_life_default
if "freshness_half_life_days" in value:
v = _as_float(value.get("freshness_half_life_days"))
if v is None or not math.isfinite(float(v)):
if strict:
raise ApiError(
code=ErrorCode.BAD_REQUEST,
message="Invalid random.recommendation.freshness_half_life_days",
status_code=400,
)
else:
freshness_half_life_days = float(max(0.1, min(float(v), 3650.0)))
velocity_smooth_default = float(_DEFAULT_RECOMMENDATION["velocity_smooth_days"])
velocity_smooth_days = velocity_smooth_default
if "velocity_smooth_days" in value:
v = _as_float(value.get("velocity_smooth_days"))
if v is None or not math.isfinite(float(v)):
if strict:
raise ApiError(
code=ErrorCode.BAD_REQUEST,
message="Invalid random.recommendation.velocity_smooth_days",
status_code=400,
)
else:
velocity_smooth_days = float(max(0.0, min(float(v), 3650.0)))
score_weights_default = _DEFAULT_RECOMMENDATION["score_weights"]
score_weights_raw = value.get("score_weights")
if score_weights_raw is None:
@@ -202,6 +315,8 @@ def _normalize_recommendation(value: Any, *, strict: bool) -> dict[str, Any]:
"pick_mode": pick_mode,
"temperature": temperature,
"score_weights": score_weights,
"freshness_half_life_days": float(freshness_half_life_days),
"velocity_smooth_days": float(velocity_smooth_days),
"multipliers": multipliers,
}
@@ -244,8 +359,10 @@ async def get_settings(
if k in runtime.random_defaults:
random_defaults[k] = runtime.random_defaults[k]
random_defaults["recommendation"] = _normalize_recommendation(runtime.random_defaults.get("recommendation"), strict=False)
random_defaults["dedup"] = _normalize_dedup(runtime.random_defaults.get("dedup"), strict=False)
else:
random_defaults["recommendation"] = _normalize_recommendation(None, strict=False)
random_defaults["dedup"] = _normalize_dedup(None, strict=False)
return {
"ok": True,
@@ -434,6 +551,9 @@ async def update_settings(
if "recommendation" in random:
defaults["recommendation"] = _normalize_recommendation(random.get("recommendation"), strict=True)
if "dedup" in random:
defaults["dedup"] = _normalize_dedup(random.get("dedup"), strict=True)
if defaults:
values = await fetch_runtime_settings(request.app.state.engine)
runtime = runtime_config_from_values(values)
+18
View File
@@ -23,6 +23,11 @@ def _build_docs_html(*, base_url: str) -> str:
"img_pixiv_cat": u("/random?pixiv_cat=1"),
"img_pixiv_re": u("/random?pixiv_cat=1&pximg_mirror_host=re"),
"img_redirect": u("/random?redirect=1"),
"rec_tune": u(
"/random?strategy=quality&rec_pick_mode=weighted&rec_temperature=1"
"&rec_w_freshness=2&rec_fresh_half_life_days=14"
"&rec_w_bookmark_velocity=2&rec_velocity_smooth_days=2"
),
"status": u("/status"),
"status_json": u("/status.json"),
"wtf": u("/wtf"),
@@ -417,6 +422,19 @@ def _build_docs_html(*, base_url: str) -> str:
</tbody>
</table>
</div>
<div class="note" style="margin-top: 12px;">
<strong>推荐策略 URL 覆盖(rec_*)</strong><br/>
这些参数仅影响<strong>本次请求</strong>,不需要登录,也不会修改后台默认配置:
<ul style="margin: 10px 0 0 18px;">
<li><code>rec_pick_mode</code>: <code>best</code> / <code>weighted</code></li>
<li><code>rec_temperature</code>: 随机温度(越小越“挑最高分”)</li>
<li><code>rec_w_*</code>: 覆盖 score_weights(如 <code>rec_w_bookmark</code> / <code>rec_w_freshness</code>)</li>
<li><code>rec_m_*</code>: 覆盖 multipliers(如 <code>rec_m_ai</code> / <code>rec_m_manga</code>)</li>
<li><code>rec_fresh_half_life_days</code>: 新鲜度半衰期(天)</li>
<li><code>rec_velocity_smooth_days</code>: 增长率平滑(天)</li>
</ul>
示例:<a href="{examples["rec_tune"]}">{examples["rec_tune"]}</a>
</div>
<div class="footer">
其它接口:<a href="{examples["tags_api"]}">/tags</a>(标签列表)、<a href="{examples["authors_api"]}">/authors</a>(作者列表)。<br/>
OpenAPI:<a href="{examples["openapi"]}">/openapi.json</a>,Swagger:<a href="{examples["swagger"]}">/api/docs</a>。<br/>
+250 -26
View File
@@ -37,37 +37,42 @@ _MAX_TAG_FILTERS = 50
_MAX_TAG_OR_TERMS = 20
_MAX_TAG_TOTAL_TERMS = 200
# Best-effort anti-repeat (process-local): reduce short-term duplicates without extra DB writes.
# Best-effort global de-dup (process-local): reduce short-term duplicates without extra DB writes.
_RECENT_LOCK = Lock()
_RECENT_WINDOW_S = 20.0 * 60.0
_RECENT_MAX_IMAGES = 600
_RECENT_MAX_AUTHORS = 200
_RECENT_IMAGES: deque[tuple[float, int]] = deque()
_RECENT_AUTHORS: deque[tuple[float, int]] = deque()
def _prune_recent(now: float) -> None:
cutoff = float(now) - float(_RECENT_WINDOW_S)
def _prune_recent(now: float, *, window_s: float, max_images: int, max_authors: int) -> None:
cutoff = float(now) - float(window_s)
while _RECENT_IMAGES and float(_RECENT_IMAGES[0][0]) < cutoff:
_RECENT_IMAGES.popleft()
while _RECENT_AUTHORS and float(_RECENT_AUTHORS[0][0]) < cutoff:
_RECENT_AUTHORS.popleft()
while len(_RECENT_IMAGES) > int(_RECENT_MAX_IMAGES):
while len(_RECENT_IMAGES) > int(max_images):
_RECENT_IMAGES.popleft()
while len(_RECENT_AUTHORS) > int(_RECENT_MAX_AUTHORS):
while len(_RECENT_AUTHORS) > int(max_authors):
_RECENT_AUTHORS.popleft()
def _get_recent_sets(now: float) -> tuple[set[int], set[int]]:
def _get_recent_sets(now: float, *, window_s: float, max_images: int, max_authors: int) -> tuple[set[int], set[int]]:
with _RECENT_LOCK:
_prune_recent(now)
_prune_recent(now, window_s=float(window_s), max_images=int(max_images), max_authors=int(max_authors))
recent_images = {int(image_id) for _t, image_id in _RECENT_IMAGES}
recent_authors = {int(user_id) for _t, user_id in _RECENT_AUTHORS}
return recent_images, recent_authors
def _record_recent(*, now: float, image_id: int, user_id: int | None) -> None:
def _record_recent(
*,
now: float,
image_id: int,
user_id: int | None,
window_s: float,
max_images: int,
max_authors: int,
) -> None:
try:
image_id_i = int(image_id)
except Exception:
@@ -81,12 +86,26 @@ def _record_recent(*, now: float, image_id: int, user_id: int | None) -> None:
user_id_i = None
with _RECENT_LOCK:
_prune_recent(now)
_prune_recent(now, window_s=float(window_s), max_images=int(max_images), max_authors=int(max_authors))
_RECENT_IMAGES.append((float(now), int(image_id_i)))
if user_id_i is not None and user_id_i > 0:
_RECENT_AUTHORS.append((float(now), int(user_id_i)))
def _as_bool(value: Any) -> bool | None:
if isinstance(value, bool):
return value
if isinstance(value, int) and value in (0, 1):
return bool(value)
if isinstance(value, str):
v = value.strip().lower()
if v in {"true", "1", "yes", "y", "on"}:
return True
if v in {"false", "0", "no", "n", "off"}:
return False
return None
def _as_nonneg_int(value: Any) -> int:
if value is None:
return 0
@@ -116,6 +135,11 @@ _DEFAULT_RECOMMENDATION: dict[str, Any] = {
"pick_mode": "weighted",
"temperature": 1.0,
"score_weights": dict(_DEFAULT_SCORE_WEIGHTS),
# Newness/trending knobs:
# - freshness_half_life_days: used by the freshness decay term (see scoring).
# - velocity_smooth_days: smoothing for bookmark velocity denominator (age_days + smooth).
"freshness_half_life_days": 21.0,
"velocity_smooth_days": 2.0,
"multipliers": {
"ai": 1.0,
"non_ai": 1.0,
@@ -139,6 +163,94 @@ def _as_float(value: Any, *, default: float) -> float:
return float(default)
def _parse_recommendation_overrides_from_query(query_params: Any) -> tuple[dict[str, Any], list[str]]:
qp = query_params or {}
used: list[str] = []
overrides: dict[str, Any] = {}
def _get_str(name: str) -> str | None:
try:
raw = qp.get(name)
except Exception:
raw = None
if raw is None:
return None
s = str(raw).strip()
if not s:
return None
used.append(name)
return s
def _get_float(name: str) -> float | None:
try:
raw = qp.get(name)
except Exception:
raw = None
if raw is None:
return None
v = _as_float(raw, default=float("nan"))
if not math.isfinite(float(v)):
raise ApiError(code=ErrorCode.BAD_REQUEST, message=f"Unsupported {name}", status_code=400)
used.append(name)
return float(v)
# Be strict for rec_* prefixes to avoid silent typos.
try:
keys = list(qp.keys())
except Exception:
keys = []
for k in keys:
if not isinstance(k, str):
continue
if k.startswith("rec_w_"):
weight_key = k.removeprefix("rec_w_")
if weight_key not in _DEFAULT_SCORE_WEIGHTS:
raise ApiError(code=ErrorCode.BAD_REQUEST, message=f"Unsupported {k}", status_code=400)
if k.startswith("rec_m_"):
mult_key = k.removeprefix("rec_m_")
if mult_key not in _DEFAULT_RECOMMENDATION["multipliers"]:
raise ApiError(code=ErrorCode.BAD_REQUEST, message=f"Unsupported {k}", status_code=400)
pick_mode_s = _get_str("rec_pick_mode")
if pick_mode_s is not None:
candidate = pick_mode_s.strip().lower()
if candidate not in {"best", "weighted"}:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported rec_pick_mode", status_code=400)
overrides["pick_mode"] = candidate
temperature_v = _get_float("rec_temperature")
if temperature_v is not None:
overrides["temperature"] = float(max(0.05, min(float(temperature_v), 100.0)))
freshness_half_life_v = _get_float("rec_fresh_half_life_days")
if freshness_half_life_v is not None:
overrides["freshness_half_life_days"] = float(max(0.1, min(float(freshness_half_life_v), 3650.0)))
velocity_smooth_v = _get_float("rec_velocity_smooth_days")
if velocity_smooth_v is not None:
overrides["velocity_smooth_days"] = float(max(0.0, min(float(velocity_smooth_v), 3650.0)))
score_overrides: dict[str, float] = {}
for key in _DEFAULT_SCORE_WEIGHTS.keys():
v = _get_float(f"rec_w_{key}")
if v is None:
continue
score_overrides[key] = float(max(-100.0, min(float(v), 100.0)))
if score_overrides:
overrides["score_weights"] = score_overrides
mult_overrides: dict[str, float] = {}
for key in _DEFAULT_RECOMMENDATION["multipliers"].keys():
v = _get_float(f"rec_m_{key}")
if v is None:
continue
mult_overrides[key] = float(max(0.0, min(float(v), 100.0)))
if mult_overrides:
overrides["multipliers"] = mult_overrides
return overrides, used
def _quality_score(image: Any, *, weights: dict[str, float] | None = None) -> float:
w = weights or _DEFAULT_SCORE_WEIGHTS
w_bookmark = float(w.get("bookmark", _DEFAULT_SCORE_WEIGHTS["bookmark"]))
@@ -592,11 +704,72 @@ async def random_image(
rng = random.Random(seed_norm) if seed_norm else random
time_boost_enabled = not bool(seed_norm)
anti_repeat_enabled = bool(time_boost_enabled) and user_id is None and illust_id is None
dedup_enabled_setting = True
dedup_window_s = 20.0 * 60.0
dedup_max_images = 5000
dedup_max_authors = 2000
dedup_strict = False
dedup_image_penalty = 8.0
dedup_author_penalty = 2.5
dedup_raw = random_defaults.get("dedup")
if isinstance(dedup_raw, dict):
v = _as_bool(dedup_raw.get("enabled"))
if v is not None:
dedup_enabled_setting = bool(v)
window_raw = dedup_raw.get("window_s")
if window_raw is not None:
try:
dedup_window_s = float(max(0.0, min(float(window_raw), 24.0 * 60.0 * 60.0)))
except Exception:
pass
max_images_raw = dedup_raw.get("max_images")
if max_images_raw is not None:
try:
dedup_max_images = int(max(1, min(int(max_images_raw), 200_000)))
except Exception:
pass
max_authors_raw = dedup_raw.get("max_authors")
if max_authors_raw is not None:
try:
dedup_max_authors = int(max(1, min(int(max_authors_raw), 200_000)))
except Exception:
pass
v = _as_bool(dedup_raw.get("strict"))
if v is not None:
dedup_strict = bool(v)
image_pen_raw = dedup_raw.get("image_penalty")
if image_pen_raw is not None:
try:
v = float(image_pen_raw)
if math.isfinite(v):
dedup_image_penalty = float(max(0.0, min(v, 1000.0)))
except Exception:
pass
author_pen_raw = dedup_raw.get("author_penalty")
if author_pen_raw is not None:
try:
v = float(author_pen_raw)
if math.isfinite(v):
dedup_author_penalty = float(max(0.0, min(v, 1000.0)))
except Exception:
pass
anti_repeat_enabled = bool(dedup_enabled_setting) and bool(time_boost_enabled) and user_id is None and illust_id is None
recent_image_ids: set[int] = set()
recent_author_ids: set[int] = set()
if anti_repeat_enabled:
recent_image_ids, recent_author_ids = _get_recent_sets(time.monotonic())
recent_image_ids, recent_author_ids = _get_recent_sets(
time.monotonic(),
window_s=float(dedup_window_s),
max_images=int(dedup_max_images),
max_authors=int(dedup_max_authors),
)
strategy_raw = (strategy or "").strip().lower()
strategy_source = "query"
@@ -687,6 +860,21 @@ async def random_image(
recommendation_source = "runtime"
recommendation_obj = dict(recommendation_raw)
rec_overrides, rec_override_keys = _parse_recommendation_overrides_from_query(getattr(request, "query_params", None))
if rec_overrides:
recommendation_source = "query"
# Shallow merge: keep unspecified runtime defaults, override only what user passes.
recommendation_obj = dict(recommendation_obj)
for k, v in rec_overrides.items():
if k in {"score_weights", "multipliers"}:
base_raw = recommendation_obj.get(k)
base = dict(base_raw) if isinstance(base_raw, dict) else {}
if isinstance(v, dict):
base.update(v)
recommendation_obj[k] = base
else:
recommendation_obj[k] = v
pick_mode_raw = str(recommendation_obj.get("pick_mode") or _DEFAULT_RECOMMENDATION["pick_mode"]).strip().lower()
if pick_mode_raw not in {"best", "weighted"}:
pick_mode_raw = str(_DEFAULT_RECOMMENDATION["pick_mode"])
@@ -709,6 +897,20 @@ async def random_image(
v = _as_float(multipliers_obj.get(key), default=float(default_value))
multipliers[key] = float(max(0.0, min(float(v), 100.0)))
freshness_half_life_days_default = float(_DEFAULT_RECOMMENDATION["freshness_half_life_days"])
freshness_half_life_days = freshness_half_life_days_default
if "freshness_half_life_days" in recommendation_obj:
v = _as_float(recommendation_obj.get("freshness_half_life_days"), default=float("nan"))
if math.isfinite(float(v)):
freshness_half_life_days = float(max(0.1, min(float(v), 3650.0)))
velocity_smooth_days_default = float(_DEFAULT_RECOMMENDATION["velocity_smooth_days"])
velocity_smooth_days = velocity_smooth_days_default
if "velocity_smooth_days" in recommendation_obj:
v = _as_float(recommendation_obj.get("velocity_smooth_days"), default=float("nan"))
if math.isfinite(float(v)):
velocity_smooth_days = float(max(0.0, min(float(v), 3650.0)))
debug_base = {
"attempts": int(attempts_i),
"attempts_source": attempts_source,
@@ -724,8 +926,18 @@ async def random_image(
"quality_samples_scaled": bool(quality_samples_scaled),
"quality_samples_source": quality_samples_source,
"anti_repeat_enabled": bool(anti_repeat_enabled),
"dedup_enabled": bool(dedup_enabled_setting),
"dedup_window_s": float(dedup_window_s),
"dedup_max_images": int(dedup_max_images),
"dedup_max_authors": int(dedup_max_authors),
"dedup_strict": bool(dedup_strict),
"dedup_image_penalty": float(dedup_image_penalty),
"dedup_author_penalty": float(dedup_author_penalty),
"time_boost_enabled": bool(time_boost_enabled),
"recommendation_source": recommendation_source,
"recommendation_query_overrides": list(rec_override_keys or []),
"freshness_half_life_days": float(freshness_half_life_days),
"velocity_smooth_days": float(velocity_smooth_days),
}
async def _pick_with_strategy(
@@ -740,7 +952,7 @@ async def random_image(
exclude_set.update(int(x) for x in recent_image_ids)
image = await pick_random_image(session, r=rng.random(), exclude_image_ids=list(exclude_set), **pick_kwargs)
if image is None and bool(anti_repeat_enabled) and recent_image_ids:
if image is None and bool(anti_repeat_enabled) and bool(recent_image_ids) and not bool(dedup_strict):
image = await pick_random_image(session, r=rng.random(), exclude_image_ids=base_exclude, **pick_kwargs)
if image is None:
return None, {**debug_base, "attempts_used": 1, "picked_by": "random_key"}
@@ -818,7 +1030,7 @@ async def random_image(
illust_type_allowed=illust_allowed,
**pick_kwargs,
)
if not images and bool(anti_repeat_enabled) and recent_image_ids:
if not images and bool(anti_repeat_enabled) and bool(recent_image_ids) and not bool(dedup_strict):
images = await pick_random_images(
session,
r=rng.random(),
@@ -834,23 +1046,25 @@ async def random_image(
for image in images:
score_base = _quality_score(image, weights=score_weights)
# 时间因素(适度):默认使用 created_at_pixiv;若缺失则 freshness 退化到 added_at,
# 以便新导入/新补全的图片也能获得“新鲜感”提升。
# 时间因素:
# - freshness: 对“老图”做衰减(loss),避免老热门长期统治(可通过权重/半衰期调节强度)。
# - bookmark_velocity: 提升“成长速度快”的作品,避免只看总收藏数。
#
# 默认使用 created_at_pixiv;若缺失则 freshness 退化到 added_at,以便新导入/新补全的图片也能有时间信号。
freshness_w = float(score_weights.get("freshness", 0.0)) if bool(time_boost_enabled) else 0.0
velocity_w = float(score_weights.get("bookmark_velocity", 0.0)) if bool(time_boost_enabled) else 0.0
freshness_contrib = 0.0
velocity_contrib = 0.0
if freshness_w != 0.0:
# 半衰期固定为 21 天:足够提升新图,但不会让老图完全失去竞争力。
half_life_days = 21.0
dt_created = _parse_iso_dt(getattr(image, "created_at_pixiv", None))
if dt_created is None:
dt_created = _parse_iso_dt(getattr(image, "added_at", None))
age_days = _age_days(dt_created)
if age_days is not None:
try:
freshness_contrib = float(freshness_w) * math.exp(-float(age_days) / float(half_life_days))
# log(decay) = - age/half_life, i.e. a true time-decay loss factor.
freshness_contrib = (-1.0) * float(freshness_w) * (float(age_days) / float(freshness_half_life_days))
except Exception:
freshness_contrib = 0.0
@@ -860,8 +1074,8 @@ async def random_image(
bookmark_count = _as_nonneg_int(getattr(image, "bookmark_count", None))
if age_days is not None and bookmark_count > 0:
try:
# +2 天平滑,避免“刚发布/刚补全”的极端值。
denom = float(age_days) + 2.0
# 平滑,避免“刚发布/刚补全”的极端值。
denom = float(age_days) + float(velocity_smooth_days)
velocity_term = math.log1p(float(bookmark_count) / max(1.0, denom))
velocity_contrib = float(velocity_w) * float(velocity_term)
except Exception:
@@ -876,13 +1090,13 @@ async def random_image(
if bool(anti_repeat_enabled):
try:
if int(getattr(image, "id", 0) or 0) in recent_image_ids:
logit -= 8.0
logit -= float(dedup_image_penalty)
except Exception:
pass
try:
uid = getattr(image, "user_id", None)
if uid is not None and int(uid) in recent_author_ids:
logit -= 2.5
logit -= float(dedup_author_penalty)
except Exception:
pass
candidates.append(
@@ -979,6 +1193,9 @@ async def random_image(
now=time.monotonic(),
image_id=int(image.id),
user_id=int(image.user_id) if getattr(image, "user_id", None) is not None else None,
window_s=float(dedup_window_s),
max_images=int(dedup_max_images),
max_authors=int(dedup_max_authors),
)
except Exception:
pass
@@ -1146,7 +1363,14 @@ async def random_image(
)
if bool(anti_repeat_enabled):
try:
_record_recent(now=time.monotonic(), image_id=int(image_id), user_id=user_id_for_recent)
_record_recent(
now=time.monotonic(),
image_id=int(image_id),
user_id=user_id_for_recent,
window_s=float(dedup_window_s),
max_images=int(dedup_max_images),
max_authors=int(dedup_max_authors),
)
except Exception:
pass
if should_mark_ok:
+9
View File
@@ -74,10 +74,19 @@ def test_admin_get_settings_returns_defaults_and_runtime_overrides(tmp_path: Pat
assert settings["random"]["fail_cooldown_ms"] == 600000
assert settings["random"]["strategy"] == "quality"
assert settings["random"]["quality_samples"] == 12
assert settings["random"]["dedup"]["enabled"] is True
assert settings["random"]["dedup"]["window_s"] == 1200
assert settings["random"]["dedup"]["max_images"] == 5000
assert settings["random"]["dedup"]["max_authors"] == 2000
assert settings["random"]["dedup"]["strict"] is False
assert settings["random"]["dedup"]["image_penalty"] == 8.0
assert settings["random"]["dedup"]["author_penalty"] == 2.5
recommendation = settings["random"]["recommendation"]
assert recommendation["pick_mode"] == "weighted"
assert recommendation["temperature"] == 1.0
assert recommendation["freshness_half_life_days"] == 21.0
assert recommendation["velocity_smooth_days"] == 2.0
assert recommendation["score_weights"]["bookmark"] == 4.0
assert recommendation["score_weights"]["view"] == 0.5
assert recommendation["multipliers"]["ai"] == 1.0
+4
View File
@@ -84,8 +84,12 @@ def test_admin_put_settings_updates_and_get_reflects(tmp_path: Path, monkeypatch
assert settings["random"]["fail_cooldown_ms"] == 12345
assert settings["random"]["strategy"] == "random"
assert settings["random"]["quality_samples"] == 7
assert settings["random"]["dedup"]["enabled"] is True
assert settings["random"]["dedup"]["window_s"] == 1200
assert settings["random"]["recommendation"]["pick_mode"] == "best"
assert settings["random"]["recommendation"]["temperature"] == 2.5
assert settings["random"]["recommendation"]["freshness_half_life_days"] == 21.0
assert settings["random"]["recommendation"]["velocity_smooth_days"] == 2.0
assert settings["random"]["recommendation"]["score_weights"]["bookmark"] == 10.0
assert settings["random"]["recommendation"]["score_weights"]["view"] == 1.0
assert settings["random"]["recommendation"]["multipliers"]["ai"] == 0.5
@@ -157,6 +157,87 @@ def test_random_quality_strategy_picks_weighted_by_score(tmp_path: Path, monkeyp
assert isinstance(body["data"]["debug"]["quality_score"], float)
def test_random_quality_strategy_supports_rec_query_overrides(tmp_path: Path, monkeypatch) -> None:
db_path = tmp_path / "random_quality_rec_overrides.db"
db_url = "sqlite+aiosqlite:///" + db_path.as_posix()
monkeypatch.setenv("APP_ENV", "dev")
monkeypatch.setenv("DATABASE_URL", db_url)
app = create_app()
async def _seed() -> None:
async with app.state.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
Session = create_sessionmaker(app.state.engine)
async with Session() as session:
session.add_all(
[
Image(
illust_id=11,
page_index=0,
ext="jpg",
original_url="https://example.test/11.jpg",
proxy_path="/i/11.jpg",
random_key=0.10,
x_restrict=0,
ai_type=0,
illust_type=0,
width=1000,
height=1000,
bookmark_count=100,
view_count=100,
comment_count=0,
),
Image(
illust_id=22,
page_index=0,
ext="jpg",
original_url="https://example.test/22.jpg",
proxy_path="/i/22.jpg",
random_key=0.20,
x_restrict=0,
ai_type=0,
illust_type=0,
width=1000,
height=1000,
bookmark_count=1,
view_count=100000,
comment_count=0,
),
]
)
await session.commit()
await app.state.engine.dispose()
asyncio.run(_seed())
with TestClient(app) as client:
resp = client.get(
"/random",
params={
"format": "json",
"attempts": 1,
"seed": "seed_rec_override_001",
"strategy": "quality",
"quality_samples": 2,
"rec_pick_mode": "best",
"rec_w_bookmark": 0,
"rec_w_view": 2,
"rec_w_comment": 0,
"rec_w_pixels": 0,
"rec_w_bookmark_rate": 0,
},
)
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
# With bookmark weight disabled and view weight boosted, the high-view image should win.
assert int(body["data"]["image"]["illust_id"]) == 22
def test_random_strategy_random_key_matches_pick_random_image(tmp_path: Path, monkeypatch) -> None:
db_path = tmp_path / "random_strategy_random_key.db"
db_url = "sqlite+aiosqlite:///" + db_path.as_posix()
@@ -24,8 +24,17 @@ describe("RecommendationPage", () => {
const body = init.body ? JSON.parse(String(init.body)) : {};
expect(body.settings.random.strategy).toBe("quality");
expect(body.settings.random.quality_samples).toBe(12);
expect(body.settings.random.dedup.enabled).toBe(true);
expect(body.settings.random.dedup.window_s).toBe(1200);
expect(body.settings.random.dedup.max_images).toBe(5000);
expect(body.settings.random.dedup.max_authors).toBe(2000);
expect(body.settings.random.dedup.strict).toBe(false);
expect(body.settings.random.dedup.image_penalty).toBe(8);
expect(body.settings.random.dedup.author_penalty).toBe(2.5);
expect(body.settings.random.recommendation.pick_mode).toBe("weighted");
expect(body.settings.random.recommendation.temperature).toBe(1);
expect(body.settings.random.recommendation.freshness_half_life_days).toBe(21);
expect(body.settings.random.recommendation.velocity_smooth_days).toBe(2);
expect(body.settings.random.recommendation.score_weights.bookmark).toBe(4);
expect(body.settings.random.recommendation.score_weights.freshness).toBe(1);
expect(body.settings.random.recommendation.score_weights.bookmark_velocity).toBe(1.2);
@@ -44,9 +53,20 @@ describe("RecommendationPage", () => {
random: {
strategy: "quality",
quality_samples: 12,
dedup: {
enabled: true,
window_s: 1200,
max_images: 5000,
max_authors: 2000,
strict: false,
image_penalty: 8,
author_penalty: 2.5,
},
recommendation: {
pick_mode: "weighted",
temperature: 1,
freshness_half_life_days: 21,
velocity_smooth_days: 2,
score_weights: { bookmark: 4, view: 0.5, comment: 2, pixels: 1, bookmark_rate: 3, freshness: 1, bookmark_velocity: 1.2 },
multipliers: {
ai: 1,
+92 -5
View File
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Alert, Button, Card, Form, Input, InputNumber, Select, Skeleton, Space, Typography } from "antd";
import { Alert, Button, Card, Form, Input, InputNumber, Select, Skeleton, Space, Switch, Typography } from "antd";
import React, { useEffect, useMemo, useState } from "react";
import { ApiError, apiJson } from "../api/client";
@@ -35,6 +35,8 @@ type FormValues = {
w_bookmark_rate: number;
w_freshness: number;
w_bookmark_velocity: number;
freshness_half_life_days: number;
velocity_smooth_days: number;
m_ai: number;
m_non_ai: number;
@@ -44,6 +46,14 @@ type FormValues = {
m_ugoira: number;
m_unknown_illust_type: number;
dedup_enabled: boolean;
dedup_window_s: number;
dedup_max_images: number;
dedup_max_authors: number;
dedup_strict: boolean;
dedup_image_penalty: number;
dedup_author_penalty: number;
preview_seed: string;
};
@@ -62,6 +72,8 @@ const DEFAULTS: Omit<FormValues, "preview_seed"> = {
w_bookmark_rate: 3.0,
w_freshness: 1.0,
w_bookmark_velocity: 1.2,
freshness_half_life_days: 21.0,
velocity_smooth_days: 2.0,
m_ai: 1.0,
m_non_ai: 1.0,
m_unknown_ai: 1.0,
@@ -69,6 +81,13 @@ const DEFAULTS: Omit<FormValues, "preview_seed"> = {
m_manga: 1.0,
m_ugoira: 1.0,
m_unknown_illust_type: 1.0,
dedup_enabled: true,
dedup_window_s: 20 * 60,
dedup_max_images: 5000,
dedup_max_authors: 2000,
dedup_strict: false,
dedup_image_penalty: 8.0,
dedup_author_penalty: 2.5,
};
function requestIdFromError(err: unknown): string | null {
@@ -81,6 +100,17 @@ function asObject(value: unknown): Record<string, unknown> {
return value as Record<string, unknown>;
}
function asBool(value: unknown, fallback: boolean): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "number" && (value === 0 || value === 1)) return Boolean(value);
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") return true;
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") return false;
}
return fallback;
}
function asInt(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value);
if (typeof value === "string") {
@@ -138,6 +168,7 @@ export function RecommendationPage() {
const settings = asObject(query.data.settings);
const random = asObject(settings.random);
const recommendation = asObject(random.recommendation);
const dedup = asObject(random.dedup);
const scoreWeights = asObject(recommendation.score_weights);
const multipliers = asObject(recommendation.multipliers);
@@ -146,6 +177,8 @@ export function RecommendationPage() {
random_quality_samples: asInt(random.quality_samples, DEFAULTS.random_quality_samples),
pick_mode: asLowerEnum(recommendation.pick_mode, PICK_MODE_VALUES, DEFAULTS.pick_mode),
temperature: asFloat(recommendation.temperature, DEFAULTS.temperature),
freshness_half_life_days: asFloat(recommendation.freshness_half_life_days, DEFAULTS.freshness_half_life_days),
velocity_smooth_days: asFloat(recommendation.velocity_smooth_days, DEFAULTS.velocity_smooth_days),
w_bookmark: asFloat(scoreWeights.bookmark, DEFAULTS.w_bookmark),
w_view: asFloat(scoreWeights.view, DEFAULTS.w_view),
w_comment: asFloat(scoreWeights.comment, DEFAULTS.w_comment),
@@ -160,6 +193,13 @@ export function RecommendationPage() {
m_manga: asFloat(multipliers.manga, DEFAULTS.m_manga),
m_ugoira: asFloat(multipliers.ugoira, DEFAULTS.m_ugoira),
m_unknown_illust_type: asFloat(multipliers.unknown_illust_type, DEFAULTS.m_unknown_illust_type),
dedup_enabled: asBool(dedup.enabled, DEFAULTS.dedup_enabled),
dedup_window_s: asInt(dedup.window_s, DEFAULTS.dedup_window_s),
dedup_max_images: asInt(dedup.max_images, DEFAULTS.dedup_max_images),
dedup_max_authors: asInt(dedup.max_authors, DEFAULTS.dedup_max_authors),
dedup_strict: asBool(dedup.strict, DEFAULTS.dedup_strict),
dedup_image_penalty: asFloat(dedup.image_penalty, DEFAULTS.dedup_image_penalty),
dedup_author_penalty: asFloat(dedup.author_penalty, DEFAULTS.dedup_author_penalty),
preview_seed: "",
});
}, [form, query.data]);
@@ -172,8 +212,8 @@ export function RecommendationPage() {
"+ w_comment * ln(1 + comment_count)",
"+ w_pixels * ln(1 + (width*height)/1_000_000)",
"+ w_bookmark_rate * ln(1 + (bookmark_count/view_count)*1000)",
"+ w_freshness * exp(-age_days / 21)",
"+ w_bookmark_velocity * ln(1 + bookmark_count / (age_days + 2))",
"- w_freshness * (age_days / freshness_half_life_days)",
"+ w_bookmark_velocity * ln(1 + bookmark_count / (age_days + velocity_smooth_days))",
"",
"说明:有 seed 时,为保证可复现,会自动关闭 freshness/bookmark_velocity 两项。",
"最终得分会再乘以“类别倍率”(AI/插画/漫画/动图等)。",
@@ -190,9 +230,20 @@ export function RecommendationPage() {
random: {
strategy: values.random_strategy,
quality_samples: values.random_quality_samples,
dedup: {
enabled: values.dedup_enabled,
window_s: Math.max(0, Math.trunc(values.dedup_window_s || 0)),
max_images: Math.max(1, Math.trunc(values.dedup_max_images || 1)),
max_authors: Math.max(1, Math.trunc(values.dedup_max_authors || 1)),
strict: values.dedup_strict,
image_penalty: values.dedup_image_penalty,
author_penalty: values.dedup_author_penalty,
},
recommendation: {
pick_mode: values.pick_mode,
temperature: values.temperature,
freshness_half_life_days: values.freshness_half_life_days,
velocity_smooth_days: values.velocity_smooth_days,
score_weights: {
bookmark: values.w_bookmark,
view: values.w_view,
@@ -374,6 +425,36 @@ export function RecommendationPage() {
<InputNumber min={0.05} max={100} step={0.05} style={{ width: 240 }} />
</Form.Item>
<Typography.Title level={5} style={{ marginTop: 12 }}>
全局防重复(dedup)
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
开启后,同一张图片在窗口期内会尽量不重复返回(进程内 best-effort;严格模式可禁止回退重复)。
</Typography.Paragraph>
<Space wrap align="start">
<Form.Item label="启用" name="dedup_enabled" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label="窗口(秒)" name="dedup_window_s" extra="例如 1200 表示 20 分钟。">
<InputNumber min={0} max={24 * 60 * 60} step={10} style={{ width: 240 }} />
</Form.Item>
<Form.Item label="最大图片缓存" name="dedup_max_images">
<InputNumber min={1} max={200000} step={100} style={{ width: 240 }} />
</Form.Item>
<Form.Item label="最大作者缓存" name="dedup_max_authors">
<InputNumber min={1} max={200000} step={50} style={{ width: 240 }} />
</Form.Item>
<Form.Item label="严格模式" name="dedup_strict" valuePropName="checked" extra="开启后,当窗口内无可用图时不会回退重复。">
<Switch />
</Form.Item>
<Form.Item label="图片重复惩罚" name="dedup_image_penalty">
<InputNumber min={0} max={1000} step={0.5} style={{ width: 240 }} />
</Form.Item>
<Form.Item label="作者重复惩罚" name="dedup_author_penalty">
<InputNumber min={0} max={1000} step={0.5} style={{ width: 240 }} />
</Form.Item>
</Space>
<Typography.Title level={5} style={{ marginTop: 12 }}>
评分公式(score_weights)
</Typography.Title>
@@ -396,16 +477,22 @@ export function RecommendationPage() {
<Form.Item label="收藏率权重(bookmark_rate)" name="w_bookmark_rate">
<InputNumber min={-100} max={100} step={0.1} style={{ width: 240 }} />
</Form.Item>
<Form.Item label="新鲜度权重(freshness)" name="w_freshness" extra="指数衰减:exp(-age_days/21),越新越加分。">
<Form.Item label="新鲜度权重(freshness)" name="w_freshness" extra="时间衰减(loss):-w * (age_days/half_life),越老越扣分。">
<InputNumber min={-100} max={100} step={0.1} style={{ width: 240 }} />
</Form.Item>
<Form.Item label="新鲜度半衰期(天)" name="freshness_half_life_days" extra="半衰期越小,衰减越强。">
<InputNumber min={0.1} max={3650} step={0.5} style={{ width: 240 }} />
</Form.Item>
<Form.Item
label="收藏增长率权重(bookmark_velocity)"
name="w_bookmark_velocity"
extra="ln(1 + bookmark_count/(age_days+2)),帮助“好看的新图”被选中。"
extra="ln(1 + bookmark_count/(age_days + smooth_days)),帮助“好看的新图”被选中。"
>
<InputNumber min={-100} max={100} step={0.1} style={{ width: 240 }} />
</Form.Item>
<Form.Item label="增长率平滑(天)" name="velocity_smooth_days" extra="值越大,越不容易因为“刚发布”而爆表。">
<InputNumber min={0} max={3650} step={0.5} style={{ width: 240 }} />
</Form.Item>
</Space>
<Typography.Title level={5} style={{ marginTop: 12 }}>
+2 -2
View File
@@ -34,7 +34,7 @@ describe("SettingsPage", () => {
expect(body.settings.random.default_r18_strict).toBe(true);
expect(body.settings.random.fail_cooldown_ms).toBe(600000);
expect(body.settings.random.strategy).toBe("quality");
expect(body.settings.random.quality_samples).toBe(5);
expect(body.settings.random.quality_samples).toBe(12);
expect(body.settings.security.hide_origin_url_in_public_json).toBe(true);
expect(body.settings.rate_limit.pixiv_hydrate_min_interval_ms).toBe(800);
expect(body.settings.rate_limit.pixiv_hydrate_jitter_ms).toBe(200);
@@ -56,7 +56,7 @@ describe("SettingsPage", () => {
default_pool_id: "",
},
image_proxy: { use_pixiv_cat: false, pximg_mirror_host: "i.pixiv.cat", extra_pximg_mirror_hosts: [] },
random: { default_attempts: 3, default_r18_strict: true, fail_cooldown_ms: 600000, strategy: "quality", quality_samples: 5 },
random: { default_attempts: 3, default_r18_strict: true, fail_cooldown_ms: 600000, strategy: "quality", quality_samples: 12 },
security: { hide_origin_url_in_public_json: true },
rate_limit: {},
},
+1 -1
View File
@@ -153,7 +153,7 @@ export function SettingsPage() {
random_default_r18_strict: asBool(random.default_r18_strict, true),
random_fail_cooldown_ms: asInt(random.fail_cooldown_ms, 600_000),
random_strategy: asLowerEnum(random.strategy, RANDOM_STRATEGY_VALUES, "quality"),
random_quality_samples: asInt(random.quality_samples, 5),
random_quality_samples: asInt(random.quality_samples, 12),
security_hide_origin_url_in_public_json: asBool(security.hide_origin_url_in_public_json, true),
pixiv_hydrate_min_interval_ms: asInt(rateLimit.pixiv_hydrate_min_interval_ms, 800),
pixiv_hydrate_jitter_ms: asInt(rateLimit.pixiv_hydrate_jitter_ms, 200),