Files
joke/api/app/routers/generate.py
T
bwstudio 6d015bb56d feat: AI 生成增强 - 风格/长度/评分/历史/复制
- 后端 prompt 支持风格选择(6种)和字数范围
- 后端 AI 输出改为 JSON 格式,含评分和推荐理由
- GenerateRequest/Response schema 新增 style/length/score/reason 字段
- 前端新增风格选择、字数范围控件
- 前端新增随机选题按钮
- 前端新增复制内容功能
- 前端新增生成历史记录(会话级 20 条)
- 结果卡片展示 AI 评分和推荐理由
2026-06-13 16:04:45 +08:00

187 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""智能笑话生成器 API"""
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from openai import OpenAI
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.setting import AiSetting
from app.schemas.joke import GenerateRequest, GenerateResponse
router = APIRouter(prefix="/generate", tags=["生成器"])
# AI Prompt
GENERATION_PROMPT = """你是一位幽默大师,专门创作轻松搞笑的短笑话。
{context}
要求:
1. 根据场景和关键词创作一条原创笑话
2. 笑话要有反转或意外结局
3. 语言风格:{style}
4. 字数要求:{length_requirement}
5. 直接输出笑话内容,不需要解释
请严格按照以下 JSON 格式输出,不要加任何额外说明:
{{
"title": "笑话标题",
"content": "笑话正文",
"score": 8,
"reason": "这个笑话巧妙结合了场景和关键词,结尾有反转"
}}
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",
) -> str:
"""构建 AI prompt,支持风格和长度控制"""
parts = []
if scenarios:
parts.append(f"场景:{', '.join(scenarios)}")
if keywords:
parts.append(f"关键词:{', '.join(keywords)}")
if not parts:
parts.append("场景:日常生活的各种趣事(不指定具体场景)")
return GENERATION_PROMPT.format(
context="\n".join(parts),
style=STYLE_MAP.get(style, "反转 / 神转折"),
length_requirement=LENGTH_MAP.get(length, "80-150字,正常长度"),
)
@router.post("", response_model=GenerateResponse)
def generate_joke(
req: GenerateRequest,
db: Session = Depends(get_db),
):
"""调用 AI 生成笑话,支持风格和长度控制"""
# 获取激活的 AI 配置
setting = db.query(AiSetting).filter(AiSetting.is_active == True).first()
if not setting:
setting = db.query(AiSetting).first()
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)
response = client.chat.completions.create(
model=setting.model_name,
messages=[{"role": "user", "content": prompt}],
temperature=req.temperature,
max_tokens=setting.max_tokens,
)
raw = response.choices[0].message.content
return _parse_json_response(raw)
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="AI 返回格式异常,请重新生成")
except Exception as e:
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
def _parse_json_response(raw: str | None) -> GenerateResponse:
"""解析 AI 返回的 JSON 格式"""
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
raw = raw[start:end]
data = json.loads(raw)
except (ValueError, json.JSONDecodeError):
return _parse_response(raw)
return GenerateResponse(
title=data.get("title", "生成的笑话"),
content=data.get("content", ""),
score=data.get("score", 0),
reason=data.get("reason", ""),
created_at=datetime.now(),
)
def _parse_response(raw: str | None) -> GenerateResponse:
"""解析 AI 返回内容,提取标题和内容"""
# 防御:处理空或 None 输入
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:
lines[i] = ""
break
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 = "(内容生成失败,请重新生成)"
return GenerateResponse(
title=title or "生成的笑话",
content=content.strip(),
created_at=datetime.now(),
)