fix: resolve 10 code review issues
High priority: - Fix concurrent race condition for view_count/like_count (atomic update) - Add route request ID tracking to prevent race conditions - Filter get_joke by status=approved (no pending content leak) - Add error feedback for like button Performance: - Optimize random joke query (avoid full table sort) - Limit page_size max to 100 (DoS prevention) Medium: - Add localStorage quota error handling - Handle empty AI response gracefully - Fix generate content title extraction Low: - Add rejected_jokes to stats API - Update dashboard to show rejected count
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
笑话优化 CLI 工具。
|
||||
循环检测所有笑话 → AI 质量检测 → AI 润色 → AI 评价分类。
|
||||
|
||||
用法:
|
||||
python optimizer/main.py # 处理所有 pending/approved 笑话
|
||||
python optimizer/main.py --status rejected # 只处理已拒绝的
|
||||
python optimizer/main.py --limit 10 # 只处理前 10 条
|
||||
python optimizer/main.py --id 1,2,3 # 指定 ID 处理
|
||||
python optimizer/main.py --api-url http://localhost:8001
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from optimizer.optimizer import Optimizer
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="笑话优化工具 — 质量检测 / AI 润色 / 评价分类")
|
||||
|
||||
parser.add_argument(
|
||||
"--api-url",
|
||||
type=str,
|
||||
default=os.getenv("API_URL", "http://localhost:8001"),
|
||||
help="API 地址(默认: http://localhost:8001)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--username",
|
||||
type=str,
|
||||
default=os.getenv("API_USERNAME", "admin"),
|
||||
help="管理员用户名(默认: admin)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--password",
|
||||
type=str,
|
||||
default=os.getenv("API_PASSWORD", "admin123"),
|
||||
help="管理员密码(默认: admin123)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
type=str,
|
||||
default=None,
|
||||
choices=["pending", "approved", "rejected"],
|
||||
help="按状态筛选(默认: 全部)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=None,
|
||||
help="最多处理 N 条(默认: 全部)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--id",
|
||||
type=str,
|
||||
default=None,
|
||||
dest="ids",
|
||||
help="指定笑话 ID,逗号分隔(如: 1,2,3)",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
ids = None
|
||||
if args.ids:
|
||||
ids = [int(x.strip()) for x in args.ids.split(",") if x.strip()]
|
||||
|
||||
print(f"=" * 50)
|
||||
print(f"笑话优化工具")
|
||||
print(f"API 地址: {args.api_url}")
|
||||
print(f"筛选状态: {args.status or '全部'}")
|
||||
if args.limit:
|
||||
print(f"处理上限: {args.limit} 条")
|
||||
if ids:
|
||||
print(f"指定 ID: {ids}")
|
||||
print(f"=" * 50)
|
||||
|
||||
optimizer = Optimizer(
|
||||
api_base=args.api_url,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
)
|
||||
|
||||
try:
|
||||
optimizer.run(status=args.status, limit=args.limit, ids=ids)
|
||||
except KeyboardInterrupt:
|
||||
print("\n用户中断")
|
||||
except Exception as e:
|
||||
print(f"\n[!] 运行错误: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,341 @@
|
||||
"""笑话优化核心逻辑:质量检测 → AI 润色 → 评价分类。"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
from optimizer.prompts import (
|
||||
QUALITY_CHECK_SYSTEM_PROMPT,
|
||||
QUALITY_CHECK_USER_PROMPT,
|
||||
POLISH_SYSTEM_PROMPT,
|
||||
POLISH_USER_PROMPT,
|
||||
EVALUATE_SYSTEM_PROMPT,
|
||||
EVALUATE_USER_PROMPT,
|
||||
)
|
||||
|
||||
|
||||
class Optimizer:
|
||||
def __init__(self, api_base: str, username: str, password: str):
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.token = None
|
||||
self.ai_client = None
|
||||
self.model_name = ""
|
||||
self.types = []
|
||||
self.crowds = []
|
||||
# 统计
|
||||
self.stats = {"checked": 0, "rejected": 0, "polished": 0, "evaluated": 0, "skipped": 0}
|
||||
|
||||
# === API 认证 ===
|
||||
def _login(self) -> str:
|
||||
resp = httpx.post(
|
||||
f"{self.api_base}/api/auth/login",
|
||||
json={"username": self.username, "password": self.password},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["access_token"]
|
||||
|
||||
def _get(self, path: str) -> dict | list:
|
||||
resp = httpx.get(
|
||||
f"{self.api_base}{path}",
|
||||
headers={"Authorization": f"Bearer {self.token}"},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def _put(self, path: str, data: dict) -> dict:
|
||||
resp = httpx.put(
|
||||
f"{self.api_base}{path}",
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {self.token}"},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# === 初始化 ===
|
||||
def setup(self):
|
||||
print("[*] 正在登录...")
|
||||
self.token = self._login()
|
||||
print("[*] 登录成功")
|
||||
|
||||
ai_config = self._get("/api/admin/settings/active")
|
||||
self.ai_client = OpenAI(
|
||||
base_url=ai_config["api_base"],
|
||||
api_key=ai_config["api_key"],
|
||||
)
|
||||
self.model_name = ai_config["model_name"]
|
||||
print(f"[*] AI 模型: {self.model_name}")
|
||||
|
||||
self.types = self._get("/api/categories/types")
|
||||
self.crowds = self._get("/api/categories/crowds")
|
||||
print(f"[*] 分类: {len(self.types)} 种类型, {len(self.crowds)} 种人群")
|
||||
|
||||
# === 读取笑话 ===
|
||||
def get_jokes(self, status: str | None = None, limit: int | None = None,
|
||||
ids: list[int] | None = None) -> list[dict]:
|
||||
"""从 API 分页读取笑话"""
|
||||
if ids:
|
||||
jokes = []
|
||||
for jid in ids:
|
||||
try:
|
||||
j = self._get(f"/api/admin/jokes/{jid}")
|
||||
jokes.append(j)
|
||||
except Exception as e:
|
||||
print(f" [!] 获取笑话 #{jid} 失败: {e}")
|
||||
return jokes
|
||||
|
||||
page = 1
|
||||
page_size = 100
|
||||
all_jokes = []
|
||||
|
||||
while True:
|
||||
try:
|
||||
path = f"/api/admin/jokes?page={page}&page_size={page_size}"
|
||||
if status:
|
||||
path += f"&status={status}"
|
||||
data = self._get(path)
|
||||
items = data.get("items", [])
|
||||
if not items:
|
||||
break
|
||||
all_jokes.extend(items)
|
||||
print(f" [*] 已读取 {len(all_jokes)} 条...")
|
||||
if limit and len(all_jokes) >= limit:
|
||||
all_jokes = all_jokes[:limit]
|
||||
break
|
||||
page += 1
|
||||
except Exception as e:
|
||||
print(f" [!] 分页读取失败 (page={page}): {e}")
|
||||
break
|
||||
|
||||
return all_jokes
|
||||
|
||||
# === Stage 1: 质量检测 ===
|
||||
def quality_check(self, content: str) -> dict:
|
||||
"""判断笑话是否有笑点,返回 {"has_punchline": bool, "reason": str}"""
|
||||
resp = self.ai_client.chat.completions.create(
|
||||
model=self.model_name,
|
||||
messages=[
|
||||
{"role": "system", "content": QUALITY_CHECK_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": QUALITY_CHECK_USER_PROMPT.format(content=content[:2000])},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=200,
|
||||
)
|
||||
raw = resp.choices[0].message.content.strip()
|
||||
return self._parse_json(raw, {"has_punchline": True, "reason": ""})
|
||||
|
||||
# === Stage 2: AI 润色 ===
|
||||
def polish(self, content: str) -> str:
|
||||
"""润色笑话内容"""
|
||||
resp = self.ai_client.chat.completions.create(
|
||||
model=self.model_name,
|
||||
messages=[
|
||||
{"role": "system", "content": POLISH_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": POLISH_USER_PROMPT.format(content=content)},
|
||||
],
|
||||
temperature=0.8,
|
||||
max_tokens=1024,
|
||||
)
|
||||
return resp.choices[0].message.content.strip()
|
||||
|
||||
# === Stage 3: 评价分类 ===
|
||||
def evaluate(self, content: str) -> dict:
|
||||
"""评价并分类,返回 {"types": [...], "crowds": [...], "score": int, "comment": str}"""
|
||||
type_names = [t.get("name", "") for t in self.types]
|
||||
crowd_names = [c.get("name", "") for c in self.crowds]
|
||||
|
||||
resp = self.ai_client.chat.completions.create(
|
||||
model=self.model_name,
|
||||
messages=[
|
||||
{"role": "system", "content": EVALUATE_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": EVALUATE_USER_PROMPT.format(
|
||||
content=content[:2000],
|
||||
known_types=", ".join(type_names),
|
||||
known_crowds=", ".join(crowd_names),
|
||||
)},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=300,
|
||||
)
|
||||
raw = resp.choices[0].message.content.strip()
|
||||
result = self._parse_json(raw, {"types": [], "crowds": [], "score": 5, "comment": ""})
|
||||
# Backward compatibility: if LLM returns old single format, convert to array
|
||||
if isinstance(result.get("types"), str):
|
||||
result["types"] = [result["types"]] if result["types"] else []
|
||||
if isinstance(result.get("crowds"), str):
|
||||
result["crowds"] = [result["crowds"]] if result["crowds"] else []
|
||||
return result
|
||||
|
||||
# === 辅助方法 ===
|
||||
def _parse_json(self, raw: str, default: dict) -> dict:
|
||||
"""安全解析 LLM 返回的 JSON"""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return default
|
||||
except json.JSONDecodeError:
|
||||
if "```json" in raw:
|
||||
raw = raw.split("```json")[1].split("```")[0]
|
||||
elif "```" in raw:
|
||||
raw = raw.split("```")[1].split("```")[0]
|
||||
try:
|
||||
return json.loads(raw.strip())
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _get_type_id(self, type_name: str) -> int | None:
|
||||
for t in self.types:
|
||||
if t.get("name") == type_name:
|
||||
return t.get("id")
|
||||
return None
|
||||
|
||||
def _get_crowd_id(self, crowd_name: str) -> int | None:
|
||||
for c in self.crowds:
|
||||
if c.get("name") == crowd_name:
|
||||
return c.get("id")
|
||||
return None
|
||||
|
||||
# === 单条笑话处理(3 阶段) ===
|
||||
def process_joke(self, joke: dict) -> bool:
|
||||
"""处理单条笑话:质量检测 → 润色 → 评价分类,返回是否成功"""
|
||||
joke_id = joke.get("id")
|
||||
title = joke.get("title", "")
|
||||
content = joke.get("content", "")
|
||||
|
||||
if not content:
|
||||
print(f" [!] #{joke_id} 内容为空,跳过")
|
||||
self.stats["skipped"] += 1
|
||||
return False
|
||||
|
||||
print(f"\n {'='*40}")
|
||||
print(f" 处理 #{joke_id}: {title[:30]}")
|
||||
print(f" {'='*40}")
|
||||
|
||||
# Stage 1: 质量检测
|
||||
print(f" [1/3] 质量检测...")
|
||||
try:
|
||||
check = self.quality_check(content)
|
||||
if not check.get("has_punchline", True):
|
||||
reason = check.get("reason", "无笑点")
|
||||
print(f" [!] 无笑点: {reason}")
|
||||
# 标记为 rejected
|
||||
self._put(f"/api/admin/jokes/{joke_id}", {"status": "rejected"})
|
||||
self.stats["rejected"] += 1
|
||||
self.stats["checked"] += 1
|
||||
return True # 处理完成(已拒绝)
|
||||
print(f" [OK] 有笑点: {check.get('reason', '')}")
|
||||
except Exception as e:
|
||||
print(f" [!] 质量检测失败: {e},跳过本条")
|
||||
self.stats["skipped"] += 1
|
||||
return False
|
||||
|
||||
self.stats["checked"] += 1
|
||||
|
||||
# Stage 2: AI 润色
|
||||
print(f" [2/3] AI 润色...")
|
||||
try:
|
||||
polished = self.polish(content)
|
||||
if polished and polished != content:
|
||||
print(f" [OK] 润色完成 ({len(content)} -> {len(polished)} 字)")
|
||||
else:
|
||||
print(f" [*] 润色后无变化")
|
||||
except Exception as e:
|
||||
print(f" [!] 润色失败: {e}")
|
||||
polished = content # 润色失败时使用原文
|
||||
|
||||
# Stage 3: 评价分类
|
||||
print(f" [3/3] 评价分类...")
|
||||
try:
|
||||
eval_result = self.evaluate(polished)
|
||||
type_names = eval_result.get("types", [])
|
||||
crowd_names = eval_result.get("crowds", [])
|
||||
score = eval_result.get("score", 5)
|
||||
print(f" [OK] 类型={type_names}, 人群={crowd_names}, 评分={score}/10")
|
||||
except Exception as e:
|
||||
print(f" [!] 评价分类失败: {e}")
|
||||
type_names = []
|
||||
crowd_names = []
|
||||
score = None
|
||||
|
||||
# 保存更新
|
||||
try:
|
||||
update = {
|
||||
"polished_content": polished,
|
||||
"status": "approved" if (score or 5) >= 4 else "pending",
|
||||
}
|
||||
if type_names:
|
||||
update["type_ids"] = []
|
||||
for n in type_names:
|
||||
tid = self._get_type_id(n)
|
||||
if tid:
|
||||
update["type_ids"].append(tid)
|
||||
if not update["type_ids"]:
|
||||
del update["type_ids"]
|
||||
if crowd_names:
|
||||
update["crowd_ids"] = []
|
||||
for n in crowd_names:
|
||||
cid = self._get_crowd_id(n)
|
||||
if cid:
|
||||
update["crowd_ids"].append(cid)
|
||||
if not update["crowd_ids"]:
|
||||
del update["crowd_ids"]
|
||||
|
||||
self._put(f"/api/admin/jokes/{joke_id}", update)
|
||||
self.stats["polished"] += 1
|
||||
self.stats["evaluated"] += 1
|
||||
print(f" [OK] 更新成功")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" [!] 更新失败: {e}")
|
||||
return False
|
||||
|
||||
# === 主循环 ===
|
||||
def run(self, status: str | None = None, limit: int | None = None,
|
||||
ids: list[int] | None = None):
|
||||
"""主入口:读取笑话并逐个处理"""
|
||||
print(f"\n>> 笑话优化模式启动")
|
||||
print(f" 筛选状态: {status or '全部'}")
|
||||
if limit:
|
||||
print(f" 处理数量: {limit}")
|
||||
if ids:
|
||||
print(f" 指定 ID: {ids}")
|
||||
|
||||
self.setup()
|
||||
|
||||
jokes = self.get_jokes(status, limit, ids)
|
||||
print(f"\n[*] 共读取 {len(jokes)} 条笑话,开始处理")
|
||||
|
||||
for idx, joke in enumerate(jokes):
|
||||
print(f"\n --- 进度 {idx+1}/{len(jokes)} ---")
|
||||
try:
|
||||
self.process_joke(joke)
|
||||
except KeyboardInterrupt:
|
||||
print("\n用户中断")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f" [!] 处理异常: {e}")
|
||||
self.stats["skipped"] += 1
|
||||
|
||||
# 每条间稍等,避免 API 限流
|
||||
if idx < len(jokes) - 1:
|
||||
time.sleep(1)
|
||||
|
||||
# 输出统计
|
||||
print(f"\n{'='*40}")
|
||||
print(f" 处理完成")
|
||||
print(f" {'='*40}")
|
||||
print(f" 检查: {self.stats['checked']} 条")
|
||||
print(f" 拒绝(无笑点): {self.stats['rejected']} 条")
|
||||
print(f" 润色: {self.stats['polished']} 条")
|
||||
print(f" 评价分类: {self.stats['evaluated']} 条")
|
||||
print(f" 跳过(失败): {self.stats['skipped']} 条")
|
||||
print(f"{'='*40}")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""AI 提示词模板 — 笑话质量检测、润色、评价分类。"""
|
||||
|
||||
# ===== Stage 1: 质量检测 =====
|
||||
QUALITY_CHECK_SYSTEM_PROMPT = """你是一个幽默内容审核专家。判断以下内容是否是一个合格的笑话/段子。
|
||||
|
||||
合格标准(满足任一即可):
|
||||
1. 有明确的笑点或反转(punchline)
|
||||
2. 有幽默的语言表达或双关
|
||||
3. 有意外结局或情理之中意料之外
|
||||
|
||||
不合格标准(符合任一即判定不合格):
|
||||
1. 纯粹的事实陈述,没有任何幽默元素
|
||||
2. 只是对话片段,没有笑点
|
||||
3. 普通故事或叙事,没有幽默设计
|
||||
4. 说教或道理阐述
|
||||
5. 内容不完整或难以理解
|
||||
|
||||
始终返回 JSON 格式:{"has_punchline": true/false, "reason": "简要说明判断理由"}"""
|
||||
|
||||
QUALITY_CHECK_USER_PROMPT = """请判断以下内容是否为合格笑话:
|
||||
|
||||
{content}
|
||||
|
||||
返回 JSON 格式。"""
|
||||
|
||||
# ===== Stage 2: AI 润色 =====
|
||||
POLISH_SYSTEM_PROMPT = """你是一个专业的幽默文案编辑。请润色以下笑话,要求:
|
||||
1. 保持核心笑点不变
|
||||
2. 优化语言表达,使其更通顺、更精炼
|
||||
3. 增强节奏感和幽默效果,但不改变原意
|
||||
4. 字数控制在原内容的 80%-120%
|
||||
5. 不要添加额外解释或评论
|
||||
6. 直接输出润色后的内容,不要加任何前缀"""
|
||||
|
||||
POLISH_USER_PROMPT = """请润色以下笑话:
|
||||
|
||||
{content}
|
||||
|
||||
只输出润色后的笑话内容。"""
|
||||
|
||||
# ===== Stage 3: 评价分类 =====
|
||||
EVALUATE_SYSTEM_PROMPT = """你是一个笑话分类和评价专家。对给定的笑话进行分析,返回 JSON 格式的分类和评分结果。
|
||||
|
||||
要求:
|
||||
1. types: 从提供的类型列表中选择所有匹配的类型名称(数组,可以选多个)
|
||||
2. crowds: 从提供的人群列表中选择所有匹配的人群名称(数组,可以选多个)
|
||||
3. score: 1-10 分,基于幽默程度、创意和表达效果
|
||||
4. comment: 简短评语(10字以内)
|
||||
|
||||
始终返回 JSON 格式。"""
|
||||
|
||||
EVALUATE_USER_PROMPT = """笑话内容:
|
||||
{content}
|
||||
|
||||
可选类型:{known_types}
|
||||
可选人群:{known_crowds}
|
||||
|
||||
返回 JSON 格式:{{"types": ["类型1", "类型2"], "crowds": ["人群1", "人群2"], "score": 8, "comment": "简短评语"}}"""
|
||||
Reference in New Issue
Block a user