Files
beige/backend/app/api/v1/benchmarks.py
yangqianqian c1ac2a6ab9 feat(M6): 第2环标杆8维分析引擎
8维草案(banana不变量+俊达配方+3链接):标题公式/开头钩子/正文结构/
卖点呈现/情绪语气emoji/目标人群/话题标签/视觉配图节奏。
- _benchmark_prompt.py: 8维vision prompt(不打分,提炼可复用配方)
- benchmark_analyzer.py: 引擎核心(vision读截图+手填亮点→8维JSON,失败兜底)
- benchmarks.py: POST /benchmarks/{id}/analyze 端点(写features_json,流转analyze_status)
端到端实测:8维齐全0缺失,真调LLM(conf0.9非兜底),落库done,质量高(识别收藏钩子/成分背书/诚实标未体现)
2026-06-16 14:28:17 +08:00

211 lines
7.4 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.
"""
app/api/v1/benchmarks.py — 标杆笔记 + 违禁词路由(管理员)
主通道:截图+手填亮点(不做原始抓取)。
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import ok, paginate, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
from app.models.product import BannedWord, BenchmarkNote
logger = logging.getLogger(__name__)
router = APIRouter(tags=["products"])
class BenchmarkCreate(BaseModel):
screenshot_url: str | None = None
highlights: str | None = None # 手填亮点(主通道)
link_url: str | None = None # 可选,不做自动抓取
class BannedWordCreate(BaseModel):
word: str
level: str # auto_fix | soft_warn | hard_block
replacement: str | None = None
updatable: bool = True
def _fmt_benchmark(b: BenchmarkNote) -> dict:
import json
features = None
if b.features_json:
try:
features = json.loads(b.features_json)
except Exception:
features = None
return {
"id": b.id, "product_id": b.product_id,
"screenshot_url": b.screenshot_url,
"highlights": b.highlights, "link_url": b.link_url,
"features": features, "analyze_status": b.analyze_status,
"created_at": b.created_at.isoformat(),
}
def _fmt_banned(bw: BannedWord) -> dict:
return {
"id": bw.id, "word": bw.word, "level": bw.level,
"replacement": bw.replacement, "updatable": bw.updatable,
"workspace_id": bw.workspace_id,
}
# ── 标杆笔记 ────────────────────────────────────────────────
@router.get("/products/{product_id}/benchmarks")
def list_benchmarks(
product_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
items = (
db.query(BenchmarkNote)
.filter(
BenchmarkNote.product_id == product_id,
BenchmarkNote.workspace_id == current_user.workspace_id,
)
.all()
)
return ok({"items": [_fmt_benchmark(b) for b in items]})
@router.post("/products/{product_id}/benchmarks")
def create_benchmark(
product_id: int, body: BenchmarkCreate,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
b = BenchmarkNote(
workspace_id=current_user.workspace_id,
product_id=product_id,
screenshot_url=body.screenshot_url,
highlights=body.highlights,
link_url=body.link_url,
)
db.add(b); db.commit(); db.refresh(b)
return ok(_fmt_benchmark(b))
@router.post("/benchmarks/{benchmark_id}/analyze")
async def analyze_benchmark_note(
benchmark_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
"""
第2环对已存标杆笔记跑 8 维拆解。
输入源=screenshot_url 截图(vision) + highlights 手填亮点(参考)。
结果写入 features_jsonanalyze_status 流转 analyzing→done/failed。
key 链路遵基石Bplain_key 只活局部,立即清零。
"""
import json
from app.core.response import raise_business
from app.models.workspace import UserApiKey
from app.utils.fernet_utils import decrypt_key
from app.services.ai_engine.gemini_factory import build_ai_clients
from app.services.ai_engine.benchmark_analyzer import analyze_benchmark
b = db.query(BenchmarkNote).filter(
BenchmarkNote.id == benchmark_id,
BenchmarkNote.workspace_id == current_user.workspace_id,
).first()
if not b:
raise_not_found("标杆笔记不存在")
if not b.screenshot_url and not b.highlights:
raise_business("该标杆无截图也无手填亮点,无法分析")
# 读截图字节screenshot_url 存的是上传后的绝对/相对路径)
screenshot = None
if b.screenshot_url:
try:
with open(b.screenshot_url, "rb") as f:
screenshot = f.read()
except Exception as e:
logger.warning("标杆截图读取失败 id=%s: %s", benchmark_id, e)
api_key_row = db.query(UserApiKey).filter(
UserApiKey.user_id == current_user.user_id,
UserApiKey.workspace_id == current_user.workspace_id,
UserApiKey.provider.in_(["openai", "apiports"]),
).first()
if not api_key_row:
raise_business("未配置 API Key请先在设置中录入")
plain_key = decrypt_key(api_key_row.encrypted_key)
clients = build_ai_clients(plain_key)
plain_key = None
b.analyze_status = "analyzing"; db.commit()
try:
result = await analyze_benchmark(clients, screenshot, b.highlights)
finally:
await clients.aclose()
b.features_json = json.dumps(result, ensure_ascii=False)
b.analyze_status = "done" if result.get("source") != "fallback" else "failed"
db.commit(); db.refresh(b)
logger.info("benchmark analyzed id=%s status=%s source=%s",
benchmark_id, b.analyze_status, result.get("source"))
return ok({**_fmt_benchmark(b), "features": result, "analyze_status": b.analyze_status})
@router.get("/banned-words")
def list_banned_words(
page: int = 1, page_size: int = 50,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
q = db.query(BannedWord).filter(BannedWord.workspace_id == current_user.workspace_id)
total = q.count()
items = q.offset((page - 1) * page_size).limit(page_size).all()
return ok(paginate([_fmt_banned(bw) for bw in items], total, page, page_size))
@router.post("/banned-words")
def create_banned_word(
body: BannedWordCreate,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
bw = BannedWord(
workspace_id=current_user.workspace_id,
word=body.word, level=body.level,
replacement=body.replacement, updatable=body.updatable,
)
db.add(bw); db.commit(); db.refresh(bw)
return ok(_fmt_banned(bw))
@router.put("/banned-words/{word_id}")
def update_banned_word(
word_id: int, body: BannedWordCreate,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
bw = db.query(BannedWord).filter(BannedWord.id == word_id, BannedWord.workspace_id == current_user.workspace_id).first()
if not bw:
raise_not_found("违禁词不存在")
if not bw.updatable:
from app.core.response import raise_forbidden
raise_forbidden("该违禁词不可修改")
bw.word = body.word; bw.level = body.level
bw.replacement = body.replacement; bw.updatable = body.updatable
db.commit(); db.refresh(bw)
return ok(_fmt_banned(bw))
@router.delete("/banned-words/{word_id}")
def delete_banned_word(
word_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
bw = db.query(BannedWord).filter(BannedWord.id == word_id, BannedWord.workspace_id == current_user.workspace_id).first()
if not bw:
raise_not_found("违禁词不存在")
db.delete(bw); db.commit()
return ok({"deleted": word_id})