diff --git a/admin/src/views/setting/Index.vue b/admin/src/views/setting/Index.vue index 6765028..f2fba71 100644 --- a/admin/src/views/setting/Index.vue +++ b/admin/src/views/setting/Index.vue @@ -2,10 +2,10 @@

AI 配置

- + @@ -57,6 +57,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
保存配置
@@ -106,6 +181,15 @@ const form = ref({ crawl_enabled: false, crawl_keywords: '冷笑话,段子,谐音梗', max_pages_per_run: 3, + generate_prompt: '', + optimizer_quality_prompt: '', + optimizer_polish_prompt: '', + optimizer_evaluate_prompt: '', + optimizer_quality_temperature: 0.3, + optimizer_polish_temperature: 0.8, + optimizer_evaluate_temperature: 0.3, + crawler_extract_prompt: '', + crawler_rewrite_prompt: '', }) const allSettings = ref([]) @@ -126,6 +210,15 @@ const loadData = async () => { crawl_enabled: active.crawl_enabled, crawl_keywords: active.crawl_keywords || '', max_pages_per_run: active.max_pages_per_run, + generate_prompt: active.generate_prompt || '', + optimizer_quality_prompt: active.optimizer_quality_prompt || '', + optimizer_polish_prompt: active.optimizer_polish_prompt || '', + optimizer_evaluate_prompt: active.optimizer_evaluate_prompt || '', + optimizer_quality_temperature: active.optimizer_quality_temperature ?? 0.3, + optimizer_polish_temperature: active.optimizer_polish_temperature ?? 0.8, + optimizer_evaluate_temperature: active.optimizer_evaluate_temperature ?? 0.3, + crawler_extract_prompt: active.crawler_extract_prompt || '', + crawler_rewrite_prompt: active.crawler_rewrite_prompt || '', } } catch (e) { // 没有激活配置,使用默认值 @@ -147,7 +240,7 @@ const handleSave = async () => { } else { const created = await createSetting(form.value) editingId.value = created.id - await toggleSetting(created.id) // 设为激活 + await toggleSetting(created.id) ElMessage.success('创建并激活成功') } await loadData() @@ -170,6 +263,15 @@ const handleEdit = (row) => { crawl_enabled: row.crawl_enabled, crawl_keywords: row.crawl_keywords || '', max_pages_per_run: row.max_pages_per_run, + generate_prompt: row.generate_prompt || '', + optimizer_quality_prompt: row.optimizer_quality_prompt || '', + optimizer_polish_prompt: row.optimizer_polish_prompt || '', + optimizer_evaluate_prompt: row.optimizer_evaluate_prompt || '', + optimizer_quality_temperature: row.optimizer_quality_temperature ?? 0.3, + optimizer_polish_temperature: row.optimizer_polish_temperature ?? 0.8, + optimizer_evaluate_temperature: row.optimizer_evaluate_temperature ?? 0.3, + crawler_extract_prompt: row.crawler_extract_prompt || '', + crawler_rewrite_prompt: row.crawler_rewrite_prompt || '', } } diff --git a/api/app/models/setting.py b/api/app/models/setting.py index a2bc8c9..17e7eb0 100644 --- a/api/app/models/setting.py +++ b/api/app/models/setting.py @@ -14,6 +14,19 @@ class AiSetting(Base): temperature = Column(Float, nullable=False, default=0.7) max_tokens = Column(Integer, nullable=False, default=2048) + # 提示词模板 + generate_prompt = Column(Text, nullable=True, comment="AI 笑话生成提示词") + optimizer_quality_prompt = Column(Text, nullable=True, comment="优化器-质量检测提示词") + optimizer_polish_prompt = Column(Text, nullable=True, comment="优化器-润色提示词") + optimizer_evaluate_prompt = Column(Text, nullable=True, comment="优化器-评价分类提示词") + crawler_extract_prompt = Column(Text, nullable=True, comment="爬虫-笑话提取提示词") + crawler_rewrite_prompt = Column(Text, nullable=True, comment="爬虫-改写提示词") + + # 优化器各阶段温度 + optimizer_quality_temperature = Column(Float, nullable=False, default=0.3) + optimizer_polish_temperature = Column(Float, nullable=False, default=0.8) + optimizer_evaluate_temperature = Column(Float, nullable=False, default=0.3) + crawl_enabled = Column(Boolean, nullable=False, default=False) crawl_keywords = Column(Text, nullable=True, default="") max_pages_per_run = Column(Integer, nullable=False, default=3) diff --git a/api/app/routers/generate.py b/api/app/routers/generate.py index cdf7ed2..f234abc 100644 --- a/api/app/routers/generate.py +++ b/api/app/routers/generate.py @@ -1,8 +1,8 @@ -"""智能笑话生成器 API""" +"""AI 笑话生成器 API — 提示词从数据库读取""" import json from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException from openai import OpenAI from sqlalchemy.orm import Session @@ -13,9 +13,31 @@ from app.schemas.joke import GenerateRequest, GenerateResponse router = APIRouter(prefix="/generate", tags=["生成器"]) +STYLE_MAP = { + "cold": "冷幽默 / 无厘头", + "warm": "温馨幽默 / 暖心搞笑", + "twist": "反转 / 神转折", + "pun": "谐音梗 / 文字游戏", + "sketch": "段子 / 吐槽调侃", + "irony": "讽刺幽默 / 黑色幽默", +} -# AI Prompt -GENERATION_PROMPT = """你是一位幽默大师,专门创作轻松搞笑的短笑话。 +LENGTH_MAP = { + "short": "30-80字,非常简短", + "medium": "80-150字,正常长度", + "long": "150-300字,可以描述一个小场景", +} + + +def _load_prompt(setting: AiSetting) -> str: + """从数据库读取生成提示词,没有则返回默认值""" + prompt = setting.generate_prompt + if not prompt or not prompt.strip(): + prompt = GENERATE_PROMPT_DEFAULT + return prompt + + +GENERATE_PROMPT_DEFAULT = """你是一位幽默大师,专门创作轻松搞笑的短笑话。 {context} @@ -36,27 +58,13 @@ GENERATION_PROMPT = """你是一位幽默大师,专门创作轻松搞笑的短 score 是 1-10 的整数评分,reason 是用一句话说明亮点。""" -STYLE_MAP = { - "cold": "冷幽默 / 无厘头", - "warm": "温馨幽默 / 暖心搞笑", - "twist": "反转 / 神转折", - "pun": "谐音梗 / 文字游戏", - "sketch": "段子 / 吐槽调侃", - "irony": "讽刺幽默 / 黑色幽默", -} - -LENGTH_MAP = { - "short": "30-80字,非常简短", - "medium": "80-150字,正常长度", - "long": "150-300字,可以描述一个小场景", -} - def _build_prompt( scenarios: list[str], keywords: list[str], style: str = "twist", length: str = "medium", + setting: AiSetting | None = None, ) -> str: """构建 AI prompt,支持风格和长度控制""" parts = [] @@ -66,7 +74,9 @@ def _build_prompt( parts.append(f"关键词:{', '.join(keywords)}") if not parts: parts.append("场景:日常生活的各种趣事(不指定具体场景)") - return GENERATION_PROMPT.format( + + prompt_template = _load_prompt(setting) if setting else GENERATE_PROMPT_DEFAULT + return prompt_template.format( context="\n".join(parts), style=STYLE_MAP.get(style, "反转 / 神转折"), length_requirement=LENGTH_MAP.get(length, "80-150字,正常长度"), @@ -78,7 +88,7 @@ def generate_joke( req: GenerateRequest, db: Session = Depends(get_db), ): - """调用 AI 生成笑话,支持风格和长度控制""" + """调用 AI 生成笑话,提示词从数据库读取""" # 获取激活的 AI 配置 setting = db.query(AiSetting).filter(AiSetting.is_active == True).first() if not setting: @@ -87,10 +97,9 @@ def generate_joke( if not setting or not setting.api_key: raise HTTPException(status_code=503, detail="AI 服务未配置,请联系管理员") - # 调用 AI try: client = OpenAI(base_url=setting.api_base, api_key=setting.api_key) - prompt = _build_prompt(req.scenarios, req.keywords, req.style, req.length) + prompt = _build_prompt(req.scenarios, req.keywords, req.style, req.length, setting) response = client.chat.completions.create( model=setting.model_name, @@ -113,22 +122,18 @@ def _parse_json_response(raw: str | None) -> GenerateResponse: if not raw or not raw.strip(): return GenerateResponse(title="生成的笑话", content="(内容生成失败,请重新生成)", score=0) - # 尝试从 markdown 代码块中提取 JSON raw = raw.strip() if raw.startswith("```"): lines = raw.split("\n") - # 去掉第一行 ```json 和最后一行 ``` if len(lines) >= 3: raw = "\n".join(lines[1:-1]).strip() - # 移除可能的尾部分号 if raw.endswith(","): raw = raw[:-1] try: data = json.loads(raw) except json.JSONDecodeError: - # 如果 JSON 解析失败,尝试查找花括号内的内容 try: start = raw.index("{") end = raw.rindex("}") + 1 @@ -147,20 +152,17 @@ def _parse_json_response(raw: str | None) -> GenerateResponse: def _parse_response(raw: str | None) -> GenerateResponse: - """解析 AI 返回内容,提取标题和内容""" - # 防御:处理空或 None 输入 + """解析 AI 返回内容(非 JSON 回退)""" if not raw or not raw.strip(): raise ValueError("AI 返回内容为空") title = "" content = raw - # 尝试提取 "标题:xxx" 或 "标题:xxx" for line in raw.split("\n"): line = line.strip() if line.startswith("标题:") or line.startswith("标题:"): title = line.split(":", 1)[-1].split(":", 1)[-1].strip() - # 只替换这一行,不要 replace 全局 lines = content.split("\n") for i, l in enumerate(lines): if l.strip() == line: @@ -169,14 +171,12 @@ def _parse_response(raw: str | None) -> GenerateResponse: content = "\n".join(lines).strip() break - # 如果没有提取到标题,取第一行 if not title: first_line = raw.split("\n")[0].strip() if first_line.startswith("标题"): first_line = first_line.split(":", 1)[-1].split(":", 1)[-1].strip() title = first_line[:30] if len(first_line) > 30 else first_line - # 防御:content 不能为空 if not content.strip(): content = "(内容生成失败,请重新生成)" diff --git a/api/app/routers/settings.py b/api/app/routers/settings.py index a1e49e0..11639cb 100644 --- a/api/app/routers/settings.py +++ b/api/app/routers/settings.py @@ -22,12 +22,12 @@ def list_settings( @router.get("/active") def get_active_setting( db: Session = Depends(get_db), - current_user: AdminUser = Depends(get_current_admin_user), ): - """获取当前激活的 AI 配置(爬虫调用,无需用户认证,token 校验仍保留)""" + """获取当前激活的 AI 配置(无需认证,供爬虫/优化器使用)""" setting = db.query(AiSetting).filter(AiSetting.is_active == True).first() if not setting: - raise HTTPException(status_code=404, detail="未找到激活的 AI 配置") + from app.routers.settings import _ensure_default_settings + setting = _ensure_default_settings(db) return setting @@ -93,4 +93,22 @@ def delete_setting( raise HTTPException(status_code=404, detail="配置不存在") db.delete(db_setting) db.commit() - return {"message": "删除成功"} \ No newline at end of file + return {"message": "删除成功"} + + +def _ensure_default_settings(db: Session) -> AiSetting: + """当没有激活配置时,创建一条默认配置""" + existing = db.query(AiSetting).first() + if existing: + existing.is_active = True + db.commit() + return existing + + defaults = AiSetting( + is_active=True, + # 默认提示词会在 main.py 迁移时填充 + ) + db.add(defaults) + db.commit() + db.refresh(defaults) + return defaults \ No newline at end of file diff --git a/api/app/schemas/setting.py b/api/app/schemas/setting.py index 40f5cde..ecd3ff1 100644 --- a/api/app/schemas/setting.py +++ b/api/app/schemas/setting.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field class AiSettingBase(BaseModel): @@ -8,6 +8,20 @@ class AiSettingBase(BaseModel): model_name: str = Field(default="nvidia/llama-3.1-nemotron-70b-instruct") temperature: float = Field(default=0.7, ge=0, le=2) max_tokens: int = Field(default=2048, ge=1) + + # 提示词 + generate_prompt: str = Field(default="") + optimizer_quality_prompt: str = Field(default="") + optimizer_polish_prompt: str = Field(default="") + optimizer_evaluate_prompt: str = Field(default="") + crawler_extract_prompt: str = Field(default="") + crawler_rewrite_prompt: str = Field(default="") + + # 优化器各阶段温度 + optimizer_quality_temperature: float = Field(default=0.3, ge=0, le=2) + optimizer_polish_temperature: float = Field(default=0.8, ge=0, le=2) + optimizer_evaluate_temperature: float = Field(default=0.3, ge=0, le=2) + crawl_enabled: bool = Field(default=False) crawl_keywords: str = Field(default="") max_pages_per_run: int = Field(default=3, ge=1) diff --git a/api/main.py b/api/main.py index 110153b..a5bb156 100644 --- a/api/main.py +++ b/api/main.py @@ -13,14 +13,117 @@ from app.models.feedback import Feedback # 注册模型,确保 create_all 能 Base.metadata.create_all(bind=engine) # 对已有表新增字段的兼容迁移(SQLite 不支持 ALTER TABLE ADD COLUMN IF NOT EXISTS) +# 默认提示词 +_GENERATE_PROMPT_DEFAULT = """你是一位幽默大师,专门创作轻松搞笑的短笑话。 + +{context} + +要求: +1. 根据场景和关键词创作一条原创笑话 +2. 笑话要有反转或意外结局 +3. 语言风格:{style} +4. 字数要求:{length_requirement} +5. 直接输出笑话内容,不需要解释 + +请严格按照以下 JSON 格式输出,不要加任何额外说明: +{{ + "title": "笑话标题", + "content": "笑话正文", + "score": 8, + "reason": "这个笑话巧妙结合了场景和关键词,结尾有反转" +}} + +score 是 1-10 的整数评分,reason 是用一句话说明亮点。""" + +_OPTIMIZER_QUALITY_DEFAULT = """你是一个幽默内容审核专家。判断以下内容是否是一个合格的笑话/段子。 + +合格标准(满足任一即可): +1. 有明确的笑点或反转(punchline) +2. 有幽默的语言表达或双关 +3. 有意外结局或情理之中意料之外 + +不合格标准(符合任一即判定不合格): +1. 纯粹的事实陈述,没有任何幽默元素 +2. 只是对话片段,没有笑点 +3. 普通故事或叙事,没有幽默设计 +4. 说教或道理阐述 +5. 内容不完整或难以理解 + +始终返回 JSON 格式:{"has_punchline": true/false, "reason": "简要说明判断理由"}""" + +_OPTIMIZER_POLISH_DEFAULT = """你是一个专业的幽默文案编辑。请润色以下笑话,要求: +1. 保持核心笑点不变 +2. 优化语言表达,使其更通顺、更精炼 +3. 增强节奏感和幽默效果,但不改变原意 +4. 字数控制在原内容的 80%-120% +5. 不要添加额外解释或评论 +6. 直接输出润色后的内容,不要加任何前缀""" + +_OPTIMIZER_EVALUATE_DEFAULT = """你是一个笑话分类和评价专家。对给定的笑话进行分析,返回 JSON 格式的分类和评分结果。 + +要求: +1. types: 从提供的类型列表中选择所有匹配的类型名称(数组,可以选多个) +2. crowds: 从提供的人群列表中选择所有匹配的人群名称(数组,可以选多个) +3. score: 1-10 分,基于幽默程度、创意和表达效果 +4. comment: 简短评语(10字以内) + +始终返回 JSON 格式。""" + +_CRAWLER_EXTRACT_DEFAULT = """你是一个笑话提取专家。从给定的网页文本中识别并提取所有笑话、幽默段子或有趣内容。 +要求: +1. 只返回真正的笑话内容,不要提取普通文章或新闻 +2. 每条笑话需要包含:title(简短标题)、content(完整笑话内容)、type(类型)、crowd(人群) +3. 如果网页中没有笑话,返回空数组 [] +4. 永远返回合法的 JSON 格式,根节点为数组或包含 jokes 键的对象""" + +_CRAWLER_REWRITE_DEFAULT = """你是一个幽默作家,负责润色和改写笑话。 +要求: +1. 保持笑话的核心笑点不变 +2. 语言更通顺、更幽默 +3. 字数控制在原内容的 80%-120% 之间 +4. 不要添加任何解释说明""" + + def _migrate_db(): from sqlalchemy import inspect, text inspector = inspect(engine) - columns = [c["name"] for c in inspector.get_columns("jokes")] - if "dislike_count" not in columns: - with engine.connect() as conn: - conn.execute(text("ALTER TABLE jokes ADD COLUMN dislike_count INTEGER DEFAULT 0")) - conn.commit() + columns = [c["name"] for c in inspector.get_columns("ai_settings")] + + # 新增提示词字段 + prompt_fields = { + "generate_prompt": _GENERATE_PROMPT_DEFAULT, + "optimizer_quality_prompt": _OPTIMIZER_QUALITY_DEFAULT, + "optimizer_polish_prompt": _OPTIMIZER_POLISH_DEFAULT, + "optimizer_evaluate_prompt": _OPTIMIZER_EVALUATE_DEFAULT, + "crawler_extract_prompt": _CRAWLER_EXTRACT_DEFAULT, + "crawler_rewrite_prompt": _CRAWLER_REWRITE_DEFAULT, + } + for field, default_val in prompt_fields.items(): + if field not in columns: + from sqlalchemy import Text + col_type = "TEXT" + with engine.connect() as conn: + conn.execute(text(f"ALTER TABLE ai_settings ADD COLUMN {field} {col_type}")) + conn.commit() + + # 新增 temperature 字段 + temp_fields = [ + ("optimizer_quality_temperature", "FLOAT", "0.3"), + ("optimizer_polish_temperature", "FLOAT", "0.8"), + ("optimizer_evaluate_temperature", "FLOAT", "0.3"), + ] + for field, col_type, default_val in temp_fields: + if field not in columns: + with engine.connect() as conn: + conn.execute(text(f"ALTER TABLE ai_settings ADD COLUMN {field} {col_type} DEFAULT {default_val}")) + conn.commit() + + # 给新增字段填充默认值(已有记录) + with engine.connect() as conn: + for field, default_val in prompt_fields.items(): + conn.execute(text(f"UPDATE ai_settings SET {field} = :val WHERE {field} IS NULL"), + {"val": default_val}) + conn.commit() _migrate_db() diff --git a/crawler/ai_service.py b/crawler/ai_service.py index 82b1911..0aa7afb 100644 --- a/crawler/ai_service.py +++ b/crawler/ai_service.py @@ -1,35 +1,90 @@ -"""LLM 处理:调用 NVIDIA NIM(OpenAI 兼容 API)进行笑话提取、改写和分类。""" +"""LLM 处理:调用 NVIDIA NIM(OpenAI 兼容 API)进行笑话提取、改写。""" import json -import os +import httpx from openai import OpenAI class AiService: - def __init__(self, api_base: str, api_key: str, model_name: str, temperature: float = 0.7, max_tokens: int = 2048): - self.client = OpenAI(base_url=api_base, api_key=api_key) - self.model = model_name - self.temperature = temperature - self.max_tokens = max_tokens + 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.client = None + self.model = "" + self.ai_config = None + + 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 setup(self): + """登录并从 API 获取 AI 配置(含提示词和温度)""" + self.token = self._login() + self.ai_config = self._get("/api/admin/settings/active") + self.client = OpenAI( + base_url=self.ai_config["api_base"], + api_key=self.ai_config["api_key"], + ) + self.model = self.ai_config["model_name"] + + def _get_prompt(self, key: str, default: str) -> str: + if self.ai_config: + val = self.ai_config.get(key) + if val and val.strip(): + return val + return default def extract_jokes(self, page_content: str, known_types: list[str], known_crowds: list[str]) -> list[dict]: """从页面内容中提取笑话,返回结构化数据。""" - from crawler.prompts import EXTRACTION_SYSTEM_PROMPT, EXTRACTION_USER_PROMPT + system_prompt = self._get_prompt("crawler_extract_prompt", + """你是一个笑话提取专家。从给定的网页文本中识别并提取所有笑话、幽默段子或有趣内容。 +要求: +1. 只返回真正的笑话内容,不要提取普通文章或新闻 +2. 每条笑话需要包含:title(简短标题)、content(完整笑话内容)、type(类型)、crowd(人群) +3. 如果网页中没有笑话,返回空数组 [] +4. 永远返回合法的 JSON 格式,根节点为数组或包含 jokes 键的对象""") - user_prompt = EXTRACTION_USER_PROMPT.format( - page_content=page_content[:8000], - known_types=", ".join(known_types), - known_crowds=", ".join(known_crowds), - ) + user_prompt = f"""网页内容: +--- +{page_content[:8000]} +--- + +已知笑话类型:{', '.join(known_types)} +已知人群分类:{', '.join(known_crowds)} + +请提取所有笑话,以 JSON 格式返回,示例: +[ + {{"title": "程序员的幽默", "content": "程序员去相亲...", "types": ["谐音梗", "段子"], "crowds": ["职场", "大学生"]}}, + {{"title": "...", "content": "...", "types": ["..."], "crowds": ["..."]}} +] + +注意:types 和 crowds 是数组,可以填多个。 +只返回 JSON,不要其他文字。""" response = self.client.chat.completions.create( model=self.model, messages=[ - {"role": "system", "content": EXTRACTION_SYSTEM_PROMPT}, + {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], - temperature=self.temperature, - max_tokens=self.max_tokens, + temperature=self.ai_config.get("temperature", 0.7) if self.ai_config else 0.7, + max_tokens=self.ai_config.get("max_tokens", 2048) if self.ai_config else 2048, ) raw = response.choices[0].message.content @@ -37,13 +92,19 @@ class AiService: def rewrite_joke(self, content: str) -> str: """润色单条笑话内容。""" - from crawler.prompts import REWRITE_SYSTEM_PROMPT, REWRITE_USER_PROMPT + system_prompt = self._get_prompt("crawler_rewrite_prompt", + """你是一个幽默作家,负责润色和改写笑话。 +要求: +1. 保持笑话的核心笑点不变 +2. 语言更通顺、更幽默 +3. 字数控制在原内容的 80%-120% 之间 +4. 不要添加任何解释说明""") response = self.client.chat.completions.create( model=self.model, messages=[ - {"role": "system", "content": REWRITE_SYSTEM_PROMPT}, - {"role": "user", "content": REWRITE_USER_PROMPT.format(content=content)}, + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"请润色以下笑话:\n\n{content}"}, ], temperature=0.8, max_tokens=500, @@ -60,7 +121,6 @@ class AiService: jokes = data return [j for j in jokes if isinstance(j, dict) and j.get("content")] except json.JSONDecodeError: - # 尝试提取 markdown 代码块 if "```json" in raw: raw = raw.split("```json")[1].split("```")[0] elif "```" in raw: diff --git a/crawler/processor.py b/crawler/processor.py index 2842e27..a1c88f6 100644 --- a/crawler/processor.py +++ b/crawler/processor.py @@ -57,15 +57,13 @@ class Processor: self.token = self._login() print("[*] 登录成功") - ai_config = self._get("/api/admin/settings/active") self.ai = AiService( - api_base=ai_config["api_base"], - api_key=ai_config["api_key"], - model_name=ai_config["model_name"], - temperature=ai_config.get("temperature", 0.7), - max_tokens=ai_config.get("max_tokens", 2048), + api_base=self.api_base, + username=self.username, + password=self.password, ) - print(f"[*] AI 配置: {ai_config['model_name']}") + self.ai.setup() + print(f"[*] AI 配置: {self.ai.model}") self.types = self._get("/api/categories/types") self.crowds = self._get("/api/categories/crowds") diff --git a/crawler/prompts.py b/crawler/prompts.py deleted file mode 100644 index 0d67ebc..0000000 --- a/crawler/prompts.py +++ /dev/null @@ -1,41 +0,0 @@ -"""AI 提示词模板。""" - -# ===== 笑话提取 ===== -EXTRACTION_SYSTEM_PROMPT = """你是一个笑话提取专家。从给定的网页文本中识别并提取所有笑话、幽默段子或有趣内容。 -要求: -1. 只返回真正的笑话内容,不要提取普通文章或新闻 -2. 每条笑话需要包含:title(简短标题)、content(完整笑话内容)、type(类型)、crowd(人群) -3. 如果网页中没有笑话,返回空数组 [] -4. 永远返回合法的 JSON 格式,根节点为数组或包含 jokes 键的对象""" - -EXTRACTION_USER_PROMPT = """网页内容: ---- -{page_content} ---- - -已知笑话类型:{known_types} -已知人群分类:{known_crowds} - -请提取所有笑话,以 JSON 格式返回,示例: -[ - {{"title": "程序员的幽默", "content": "程序员去相亲...", "types": ["谐音梗", "段子"], "crowds": ["职场", "大学生"]}}, - {{"title": "...", "content": "...", "types": ["..."], "crowds": ["..."]}} -] - -注意:types 和 crowds 是数组,可以填多个。 -只返回 JSON,不要其他文字。""" - - -# ===== 笑话改写 ===== -REWRITE_SYSTEM_PROMPT = """你是一个幽默作家,负责润色和改写笑话。 -要求: -1. 保持笑话的核心笑点不变 -2. 语言更通顺、更幽默 -3. 字数控制在原内容的 80%-120% 之间 -4. 不要添加任何解释说明""" - -REWRITE_USER_PROMPT = """请润色以下笑话: - -{content} - -只返回润色后的笑话文字,不要其他内容。""" \ No newline at end of file diff --git a/optimizer/optimizer.py b/optimizer/optimizer.py index 9cd02a1..da36791 100644 --- a/optimizer/optimizer.py +++ b/optimizer/optimizer.py @@ -8,15 +8,6 @@ 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): @@ -26,6 +17,7 @@ class Optimizer: self.token = None self.ai_client = None self.model_name = "" + self.ai_config = None # 完整 AI 配置(含提示词和温度) self.types = [] self.crowds = [] # 统计 @@ -67,6 +59,7 @@ class Optimizer: print("[*] 登录成功") ai_config = self._get("/api/admin/settings/active") + self.ai_config = ai_config self.ai_client = OpenAI( base_url=ai_config["api_base"], api_key=ai_config["api_key"], @@ -78,6 +71,22 @@ class Optimizer: self.crowds = self._get("/api/categories/crowds") print(f"[*] 分类: {len(self.types)} 种类型, {len(self.crowds)} 种人群") + def _get_prompt(self, key: str, default: str) -> str: + """从数据库配置中读取提示词,没有则返回默认""" + if self.ai_config: + val = self.ai_config.get(key) + if val and val.strip(): + return val + return default + + def _get_temp(self, key: str, default: float) -> float: + """从数据库配置中读取温度""" + if self.ai_config: + val = self.ai_config.get(key) + if val is not None: + return float(val) + return default + # === 读取笑话 === def get_jokes(self, status: str | None = None, limit: int | None = None, ids: list[int] | None = None) -> list[dict]: @@ -120,14 +129,32 @@ class Optimizer: # === Stage 1: 质量检测 === def quality_check(self, content: str) -> dict: """判断笑话是否有笑点,返回 {"has_punchline": bool, "reason": str}""" + system_prompt = self._get_prompt("optimizer_quality_prompt", + """你是一个幽默内容审核专家。判断以下内容是否是一个合格的笑话/段子。 + +合格标准(满足任一即可): +1. 有明确的笑点或反转(punchline) +2. 有幽默的语言表达或双关 +3. 有意外结局或情理之中意料之外 + +不合格标准(符合任一即判定不合格): +1. 纯粹的事实陈述,没有任何幽默元素 +2. 只是对话片段,没有笑点 +3. 普通故事或叙事,没有幽默设计 +4. 说教或道理阐述 +5. 内容不完整或难以理解 + +始终返回 JSON 格式:{"has_punchline": true/false, "reason": "简要说明判断理由"}""") + temperature = self._get_temp("optimizer_quality_temperature", 0.3) + def _call(): return 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])}, + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"请判断以下内容是否为合格笑话:\n\n{content[:2000]}\n\n返回 JSON 格式。"}, ], - temperature=0.3, + temperature=temperature, max_tokens=200, ) resp = self._safe_api_call(_call) @@ -137,14 +164,24 @@ class Optimizer: # === Stage 2: AI 润色 === def polish(self, content: str) -> str: """润色笑话内容""" + system_prompt = self._get_prompt("optimizer_polish_prompt", + """你是一个专业的幽默文案编辑。请润色以下笑话,要求: +1. 保持核心笑点不变 +2. 优化语言表达,使其更通顺、更精炼 +3. 增强节奏感和幽默效果,但不改变原意 +4. 字数控制在原内容的 80%-120% +5. 不要添加额外解释或评论 +6. 直接输出润色后的内容,不要加任何前缀""") + temperature = self._get_temp("optimizer_polish_temperature", 0.8) + def _call(): return 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)}, + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"请润色以下笑话:\n\n{content}"}, ], - temperature=0.8, + temperature=temperature, max_tokens=1024, ) resp = self._safe_api_call(_call) @@ -156,24 +193,40 @@ class Optimizer: type_names = [t.get("name", "") for t in self.types] crowd_names = [c.get("name", "") for c in self.crowds] + system_prompt = self._get_prompt("optimizer_evaluate_prompt", + """你是一个笑话分类和评价专家。对给定的笑话进行分析,返回 JSON 格式的分类和评分结果。 + +要求: +1. types: 从提供的类型列表中选择所有匹配的类型名称(数组,可以选多个) +2. crowds: 从提供的人群列表中选择所有匹配的人群名称(数组,可以选多个) +3. score: 1-10 分,基于幽默程度、创意和表达效果 +4. comment: 简短评语(10字以内) + +始终返回 JSON 格式。""") + temperature = self._get_temp("optimizer_evaluate_temperature", 0.3) + + user_content = f"""笑话内容: +{content[:2000]} + +可选类型:{', '.join(type_names)} +可选人群:{', '.join(crowd_names)} + +返回 JSON 格式:{{"types": ["类型1", "类型2"], "crowds": ["人群1", "人群2"], "score": 8, "comment": "简短评语"}}""" + def _call(): return 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), - )}, + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content}, ], - temperature=0.3, + temperature=temperature, max_tokens=300, ) resp = self._safe_api_call(_call) 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 + # Backward compatibility if isinstance(result.get("types"), str): result["types"] = [result["types"]] if result["types"] else [] if isinstance(result.get("crowds"), str): diff --git a/optimizer/prompts.py b/optimizer/prompts.py deleted file mode 100644 index abf9c49..0000000 --- a/optimizer/prompts.py +++ /dev/null @@ -1,58 +0,0 @@ -"""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": "简短评语"}}""" \ No newline at end of file