From 565946a2b0c59efbc6b03e8e885f989d6ab44615 Mon Sep 17 00:00:00 2001 From: inliver233 Date: Wed, 25 Feb 2026 23:15:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8E=A8=E8=8D=90=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E4=B8=BA=E5=A4=9A=E7=BB=B4=E5=BA=A6|=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=8E=A8=E8=8D=90=E7=AD=96=E7=95=A5=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E5=8F=82=E6=95=B0,=E6=94=AF=E6=8C=81=E4=BD=BF=E7=94=A8url?= =?UTF-8?q?=E8=87=AA=E8=A1=8C=E8=A6=86=E7=9B=96=E8=B0=83=E8=8A=82|?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=85=A8=E5=B1=80=E7=9F=AD=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E9=98=B2=E9=87=8D=E5=A4=8D=E7=B3=BB=E7=BB=9F|=E6=9B=B4?= =?UTF-8?q?=E6=96=B0docs=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/admin/settings.py | 120 ++++++++ backend/app/api/public/docs_page.py | 18 ++ backend/app/api/public/random.py | 276 ++++++++++++++++-- backend/tests/test_admin_settings_get.py | 9 + backend/tests/test_admin_settings_put.py | 4 + backend/tests/test_random_quality_strategy.py | 81 +++++ .../src/pages/RecommendationPage.test.tsx | 20 ++ frontend/src/pages/RecommendationPage.tsx | 97 +++++- frontend/src/pages/SettingsPage.test.tsx | 4 +- frontend/src/pages/SettingsPage.tsx | 2 +- 10 files changed, 597 insertions(+), 34 deletions(-) diff --git a/backend/app/api/admin/settings.py b/backend/app/api/admin/settings.py index 2f30e5d..e10cd17 100644 --- a/backend/app/api/admin/settings.py +++ b/backend/app/api/admin/settings.py @@ -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) diff --git a/backend/app/api/public/docs_page.py b/backend/app/api/public/docs_page.py index e20927c..74e1f07 100644 --- a/backend/app/api/public/docs_page.py +++ b/backend/app/api/public/docs_page.py @@ -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: +
+ 推荐策略 URL 覆盖(rec_*)
+ 这些参数仅影响本次请求,不需要登录,也不会修改后台默认配置: + + 示例:{examples["rec_tune"]} +