优化推荐策略为多维度|更新推荐策略相关参数,支持使用url自行覆盖调节|增加全局短时间防重复系统|更新docs文档
This commit is contained in:
1 parent
1f37e8ec02
commit
565946a2b0
10 files changed
+597
-34
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user