""" routes/ave_creatives.py — 创意库 API(GapEngine 落地) 前缀:/api/ave/creatives 组成: · 创意卡 CRUD(创意库) · 参数库读写(创意工坊 ①六层参数) · 矩阵配置读写(②负面矩阵 / ③正向矩阵 / ④道具场景) · 候选生成(⑤生成&筛选,LLM 出 30 条) · 缺口试算(Tab① 实时反馈) ⚠️ 路由顺序:静态段(stats/library/matrix/generate/calculate)必须定义在 `/{cid}` 通配之前,否则会被吞掉。 """ import logging import os import sys from pathlib import Path import yaml from fastapi import APIRouter, HTTPException logger = logging.getLogger("dashboard.ave_creatives") _SCRIPTS = Path(os.environ.get( "AGENT_SYNC", Path(__file__).resolve().parent.parent.parent)) / "05_tools" / "09_ave" / "scripts" if str(_SCRIPTS) not in sys.path: sys.path.insert(0, str(_SCRIPTS)) router = APIRouter(prefix="/api/ave/creatives", tags=["ave_creatives"]) def _ge(): """惰性导入 gap_engine(避免启动顺序问题)""" try: import gap_engine return gap_engine except Exception as e: raise HTTPException(500, detail=f"创意引擎不可用: {e}") # ═══════════════════════════════════════════════════════════ # 参数库(① 六层参数) # ═══════════════════════════════════════════════════════════ @router.get("/library") def api_library(): """六层参数库全量(Tab① 平铺展示用)""" ge = _ge() lib = ge.load_library() layers = [] for L in lib.layers: layers.append({ "id": L.id, "name": L.name, "params": [{ "id": p.id, "name": p.name, "values": [{"value": v.value, "intensity": v.intensity, "cue": v.cue} for v in p.values], } for p in L.params], }) n_param = sum(len(L.params) for L in lib.layers) n_val = sum(len(p.values) for L in lib.layers for p in L.params) return {"layers": layers, "stats": {"layers": len(layers), "params": n_param, "values": n_val}} @router.put("/library") def api_library_save(data: dict): """保存参数库(整份 YAML 文本;供高级编辑)""" ge = _ge() text = (data or {}).get("yaml_text", "") if not text: raise HTTPException(400, detail="需要 yaml_text") try: return {"status": "ok", "stats": ge.save_library_text(text)} except Exception as e: raise HTTPException(400, detail=str(e)) # ═══════════════════════════════════════════════════════════ # 矩阵配置(②③④) # ═══════════════════════════════════════════════════════════ @router.get("/matrix") def api_matrix(): """负面/正向/道具场景 + 生成参数""" return _ge().load_matrix() @router.put("/matrix") def api_matrix_save(data: dict): """保存矩阵(②负面勾选 / ③正向勾选 / ④道具场景 + presets)""" body = (data or {}).get("matrix") or data if not isinstance(body, dict) or "negative" not in body: raise HTTPException(400, detail="矩阵格式错误:需要 negative 字段") return {"status": "ok", "matrix": _ge().save_matrix(body)} # ═══════════════════════════════════════════════════════════ # 生成与试算(⑤) # ═══════════════════════════════════════════════════════════ @router.post("/generate") def api_generate(data: dict): """生成候选创意(LLM 出 N 条 → 缺口分 → 过滤加权 → 排序) Body: { brief: str, n?: int, matrix?: dict, selections?: [{layer_id,param_id,value}] } # 参数框架("按当前参数生成") selections 为空 = 完全随机模式(AI 自由配参数) """ ge = _ge() brief = (data or {}).get("brief", "").strip() if not brief: raise HTTPException(400, detail="需要 brief(1-2 句话场景诉求)") try: return {"status": "ok", **ge.generate_candidates( brief, n=int((data or {}).get("n") or 0), matrix=(data or {}).get("matrix") or None, constraint_selections=(data or {}).get("selections") or None)} except Exception as e: logger.warning("生成候选失败: %s", e) raise HTTPException(500, detail=str(e)) @router.post("/preselect") def api_preselect(data: dict): """AI 参数预选(Tab① 用):按场景 + 矩阵给 N 套七层参数基础搭配 Body: { brief: str, n_schemes?: int, matrix?: dict } 返回每套含 selections + 缺口分 + 思路,供前端一键填入 Tab① 后微调。 """ ge = _ge() brief = (data or {}).get("brief", "").strip() if not brief: raise HTTPException(400, detail="需要 brief(1-2 句话场景诉求)") try: return {"status": "ok", **ge.preselect_params( brief, n_schemes=int((data or {}).get("n_schemes") or 3), matrix=(data or {}).get("matrix") or None)} except Exception as e: logger.warning("AI 预选失败: %s", e) raise HTTPException(500, detail=str(e)) @router.post("/calculate") def api_calculate(data: dict): """缺口试算(Tab① 手动挑参数 → 即时看缺口分/联想卡,零费用不调 LLM) Body: { selections: [{layer_id, param_id, value}] } """ ge = _ge() lib = ge.load_library() raw = (data or {}).get("selections") or [] sels = [] for rs in raw: lid, pid, val = rs.get("layer_id", ""), rs.get("param_id", ""), rs.get("value", "") pv = lib.find_value(lid, pid, val) if pv is None: continue sels.append(ge.Selection(layer_id=lid, param_id=pid, param_name=lib.param_name(lid, pid), value=pv.value, intensity=pv.intensity, cue=pv.cue)) if len(sels) < 2: return {"status": "ok", "insufficient": True, "hint": "至少选 2 个参数才能计算缺口(照原文校验)", "count": len(sels)} g = ge.calc_gap(sels) return {"status": "ok", "insufficient": False, **g.model_dump()} # ═══════════════════════════════════════════════════════════ # 创意卡 CRUD(创意库) # ═══════════════════════════════════════════════════════════ @router.get("/stats") def api_stats(): return {"status": "ok", **_ge().store.stats()} @router.get("") @router.get("/") def api_list(q: str = "", status: str = "", inject_mode: str = "", sort: str = "updated_desc", limit: int = 200): """创意卡列表""" ge = _ge() cards = ge.store.list_cards(q=q, status=status, inject_mode=inject_mode, sort=sort, limit=min(int(limit), 1000)) return {"status": "ok", "total": len(cards), "cards": cards} @router.post("") @router.post("/") def api_create(data: dict): """新建创意卡 两种来源: 1) { candidate: {...}, brief, matrix, inject_mode } ← 从生成结果保存 2) { card: {...} } ← 手动/编辑后保存 """ ge = _ge() try: if (data or {}).get("candidate"): cand = ge.ComboCandidate(**(data["candidate"])) card = ge.candidate_to_card( cand, brief=(data or {}).get("brief", ""), title=(data or {}).get("title", ""), matrix=(data or {}).get("matrix") or None, inject_mode=(data or {}).get("inject_mode", "reference")) return {"status": "ok", "card": ge.store.create(card)} if (data or {}).get("card"): card = ge.CreativeCard(**(data["card"])) return {"status": "ok", "card": ge.store.create(card)} raise HTTPException(400, detail="需要 candidate 或 card") except HTTPException: raise except Exception as e: raise HTTPException(400, detail=f"创建失败: {e}") @router.post("/save-batch") def api_save_batch(data: dict): """批量保存(筛选后一次入库) Body: { cards: [{candidate, brief, inject_mode, title}], matrix? } """ ge = _ge() items = (data or {}).get("cards") or [] if not items: raise HTTPException(400, detail="需要 cards 数组") saved = [] for it in items: try: cand = ge.ComboCandidate(**(it.get("candidate") or {})) card = ge.candidate_to_card( cand, brief=it.get("brief", ""), title=it.get("title", ""), matrix=(data or {}).get("matrix") or None, inject_mode=it.get("inject_mode", "reference")) saved.append(ge.store.create(card)) except Exception as e: logger.warning("批量保存单条失败: %s", e) return {"status": "ok", "saved": len(saved), "cards": saved} @router.get("/{cid}") def api_get(cid: str): card = _ge().store.get(cid) if not card: raise HTTPException(404, detail="创意卡不存在") return {"status": "ok", "card": card} @router.patch("/{cid}") def api_update(cid: str, data: dict): """更新创意卡(含 inject_mode / status / title 等)""" card = _ge().store.update(cid, data or {}) if not card: raise HTTPException(404, detail="创意卡不存在") return {"status": "ok", "card": card} @router.delete("/{cid}") def api_delete(cid: str): if not _ge().store.delete(cid): raise HTTPException(404, detail="创意卡不存在") return {"status": "ok", "deleted": cid}