feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
gap_engine — 创意缺口引擎(GapEngine 落地包)
|
||||
|
||||
把"导演 1-2 句话的诉求"变成"一批可筛选的反差创意候选",再沉淀为「创意卡」资源。
|
||||
|
||||
模块:
|
||||
models.py 数据模型(照原文 7 类 + 候选/创意卡扩展)
|
||||
library.py 参数库 / 矩阵配置读写
|
||||
gap_calculator.py 缺口计算(原文口径)+ 联想卡 + 矩阵过滤加权
|
||||
combo_generator.py LLM 批量生成候选
|
||||
store.py 创意卡存储(创意库)
|
||||
|
||||
数据资产(可直接编辑):
|
||||
09_ave/config/param_library.yaml 六层参数库(6层/38参数/177值)
|
||||
09_ave/config/creative_matrix.yaml 负面/正向/道具场景矩阵
|
||||
"""
|
||||
from gap_engine.models import ( # noqa: F401
|
||||
CreativeCard, ComboCandidate, CreativeConstraints, GapResult,
|
||||
Layer, Param, ParamLibrary, ParamValue, Selection,
|
||||
)
|
||||
from gap_engine.library import ( # noqa: F401
|
||||
load_library, load_matrix, save_matrix, save_library_text,
|
||||
constraints_from_matrix, generation_cfg,
|
||||
)
|
||||
from gap_engine.gap_calculator import ( # noqa: F401
|
||||
calc_gap, build_scene_card, apply_filters, matrix_snapshot,
|
||||
)
|
||||
from gap_engine.combo_generator import ( # noqa: F401
|
||||
generate_candidates, candidate_to_card, preselect_params,
|
||||
)
|
||||
from gap_engine import store # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"ParamValue", "Param", "Layer", "ParamLibrary", "Selection", "GapResult",
|
||||
"CreativeConstraints", "ComboCandidate", "CreativeCard",
|
||||
"load_library", "load_matrix", "save_matrix", "save_library_text",
|
||||
"constraints_from_matrix", "generation_cfg",
|
||||
"calc_gap", "build_scene_card", "apply_filters", "matrix_snapshot",
|
||||
"generate_candidates", "candidate_to_card", "preselect_params", "store",
|
||||
]
|
||||
@@ -0,0 +1,453 @@
|
||||
"""
|
||||
gap_engine.combo_generator — LLM 批量生成创意候选
|
||||
|
||||
流程(GapEngine「模块2 创意组合生成器」的落地实现):
|
||||
1. 把参数库可选值清单 + 矩阵约束喂给 LLM
|
||||
2. LLM 输出 N 条候选(每条 = 六层参数选择 + 一句话联想 + 道具场景 + 敏感项标注)
|
||||
3. 用参数库把每条选择解析成 Selection(带 intensity + cue)
|
||||
4. calc_gap 算缺口分 → apply_filters 硬过滤/软标记/正向加权 → 排序
|
||||
|
||||
输出即前端"密集候选列表"的数据源。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_SCRIPTS = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
|
||||
from lib.logger import get_logger # noqa: E402
|
||||
from gap_engine.models import ( # noqa: E402
|
||||
ComboCandidate, CreativeConstraints, ParamLibrary, Selection, utcnow,
|
||||
)
|
||||
from gap_engine.gap_calculator import calc_gap, apply_filters, build_scene_card # noqa: E402
|
||||
from gap_engine.library import load_library, load_matrix, constraints_from_matrix, generation_cfg # noqa: E402
|
||||
|
||||
log = get_logger("gap_engine")
|
||||
|
||||
_SYSTEM = """你是短剧/广告导演的「反差组合生成器」。
|
||||
|
||||
你的任务:根据导演给出的 1-2 句话场景诉求,从给定的六层参数库里挑选参数组合,
|
||||
生成一批**互不相同**的候选创意。每条候选必须能让人一眼脑补出画面。
|
||||
|
||||
硬性规则:
|
||||
1. 只能从给定参数清单里取值,不得自创值名。
|
||||
2. 每层最多选 2 个参数(不同候选之间要拉开差异)。
|
||||
3. 绝不涉及"硬违禁"类内容。
|
||||
4. 尽量避开"未放开的观众反感项";如果某条创意非用不可,把它标进 needs_soft。
|
||||
5. 每条候选给一句 one_liner —— 用**人话**描述这个画面(导演据此判断想不想拍),
|
||||
不要堆术语。例:「术后第一天,他扶着输液架走出六亲不认的模特步,背景放着迪斯科」。
|
||||
6. 第七层「表达层」是"怎么表达":直述是默认;也可以用**暗喻/象征/反讽/留白**制造回味。
|
||||
用隐喻时,喻体从「象征物」素材里挑(地震/潮水/笼中鸟/镜子…),**被比喻的事不要直接说出来**,
|
||||
让观众自己完成那次替代。one_liner 里要能看出这层画面(但不必解释含义)。
|
||||
|
||||
【输出纪律(务必遵守,否则内容会被截断丢弃)】
|
||||
- one_liner 控制在 **40 字以内**,一句话说清画面,不要展开解释。
|
||||
- selections 每层**最多 1 个参数**(优先选最能体现这条差异的那个),总量 ≤ 7 条。
|
||||
- 不要输出任何 JSON 之外的文字、注释或解释。
|
||||
|
||||
输出:严格 JSON 数组,不要解释文字。"""
|
||||
|
||||
_TMPL = """【场景诉求】
|
||||
{brief}
|
||||
|
||||
【六层参数清单(只能从中取值)】
|
||||
{param_list}
|
||||
|
||||
【硬违禁(绝对不可涉及)】
|
||||
{hard_list}
|
||||
|
||||
【观众反感项(当前已放开的用 ✓ 标记;未放开的尽量避开,非用不可则标 needs_soft)】
|
||||
{soft_list}
|
||||
|
||||
【可用道具】{props}
|
||||
【可用场景变量】{scenes}
|
||||
【希望传递的正向价值(命中则计入 positive_hits)】
|
||||
{positive_list}
|
||||
|
||||
请生成 {n} 条候选创意,输出 JSON 数组,每条格式:
|
||||
{{
|
||||
"one_liner": "一句话画面联想(人话,能直接脑补)",
|
||||
"selections": [
|
||||
{{"layer_id": "narrative", "param_id": "info_release", "value": "结果前置"}},
|
||||
... 每层 0~2 个 ...
|
||||
],
|
||||
"props": ["童趣道具"],
|
||||
"scenes": ["空间反差"],
|
||||
"needs_soft": [],
|
||||
"needs_hard": [],
|
||||
"positive_hits": ["warm_twist"]
|
||||
}}"""
|
||||
|
||||
|
||||
def parse_json_lenient(llm, raw: str):
|
||||
"""容错解析:先走标准解析;失败则尝试修复"被 max_tokens 截断的 JSON"
|
||||
|
||||
截断修复思路:取到最后一个完整的 `}` 为止,补上 `]`(数组被截断的情形)。
|
||||
这能救回"前面若干条完整、最后一条断掉"的常见情况。
|
||||
"""
|
||||
r = llm.parse_json(raw)
|
||||
if r is not None:
|
||||
return r
|
||||
t = (raw or "").strip()
|
||||
if not t:
|
||||
return None
|
||||
# 去掉 markdown 围栏(可能只有开头)
|
||||
i0 = t.find("[")
|
||||
if i0 < 0:
|
||||
i0 = t.find("{")
|
||||
if i0 < 0:
|
||||
return None
|
||||
body = t[i0:]
|
||||
cut = body.rfind("}")
|
||||
if cut <= 0:
|
||||
return None
|
||||
fixed = body[:cut + 1]
|
||||
# 逐级补全:可能缺 ] 或 }
|
||||
for tail in ("\n]", "]\n]", "\n]", ""):
|
||||
try:
|
||||
return json.loads(fixed + tail)
|
||||
except Exception:
|
||||
continue
|
||||
# 再退一步:切到倒数第二个 } 再试
|
||||
cut2 = body.rfind("}", 0, cut)
|
||||
if cut2 > 0:
|
||||
for tail in ("\n]", "]"):
|
||||
try:
|
||||
return json.loads(body[:cut2 + 1] + tail)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _param_list_text(lib: ParamLibrary) -> str:
|
||||
"""参数库 → 紧凑清单文本(只给 层/参数/可选值,不给 cue 以免 prompt 过长)"""
|
||||
lines = []
|
||||
for L in lib.layers:
|
||||
lines.append(f"· {L.name}({L.id})")
|
||||
for p in L.params:
|
||||
vals = " / ".join(v.value for v in p.values)
|
||||
lines.append(f" - {p.name}({p.id}): {vals}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _matrix_texts(matrix: dict) -> tuple[str, str, str, str, str]:
|
||||
neg = matrix.get("negative") or {}
|
||||
hard = "、".join(f'{h.get("name")}({h.get("id")})' for h in (neg.get("hard") or [])) or "(无)"
|
||||
soft_items = []
|
||||
for s in (neg.get("soft") or []):
|
||||
mark = "✓已放开" if s.get("relaxed") else "未放开"
|
||||
soft_items.append(f'{s.get("name")}({s.get("id")}) [{mark}]')
|
||||
soft = "\n".join(" - " + x for x in soft_items) or " (无)"
|
||||
props = "、".join(f'{p.get("name")}' for p in (matrix.get("props") or [])) or "(无)"
|
||||
scenes = "、".join(f'{s.get("name")}' for s in (matrix.get("scenes") or [])) or "(无)"
|
||||
positive = "\n".join(f' - {p.get("name")}({p.get("id")})' for p in (matrix.get("positive") or [])) or " (无)"
|
||||
return hard, soft, props, scenes, positive
|
||||
|
||||
|
||||
def _resolve_selections(raw_sels: list[dict], lib: ParamLibrary,
|
||||
max_per_layer: int = 2) -> list[Selection]:
|
||||
"""把 LLM 的原始选择解析为 Selection(带 intensity + cue);丢弃库里不存在的值"""
|
||||
per_layer: dict[str, int] = {}
|
||||
out: list[Selection] = []
|
||||
for rs in (raw_sels or []):
|
||||
lid = str(rs.get("layer_id", "")).strip()
|
||||
pid = str(rs.get("param_id", "")).strip()
|
||||
val = str(rs.get("value", "")).strip()
|
||||
if not (lid and pid and val):
|
||||
continue
|
||||
if max_per_layer and per_layer.get(lid, 0) >= max_per_layer:
|
||||
continue
|
||||
pv = lib.find_value(lid, pid, val)
|
||||
if pv is None:
|
||||
continue # LLM 编造的值 → 丢弃
|
||||
per_layer[lid] = per_layer.get(lid, 0) + 1
|
||||
out.append(Selection(
|
||||
layer_id=lid, param_id=pid,
|
||||
param_name=lib.param_name(lid, pid),
|
||||
value=pv.value, intensity=pv.intensity, cue=pv.cue,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def generate_candidates(brief: str, n: int = 0,
|
||||
matrix: Optional[dict] = None,
|
||||
lib: Optional[ParamLibrary] = None,
|
||||
llm=None,
|
||||
constraint_selections: Optional[list[dict]] = None) -> dict:
|
||||
"""生成候选创意(完整流水线)
|
||||
|
||||
constraint_selections: 导演在 Tab① 定好的参数(作为硬框架约束每条候选);
|
||||
为空 = 完全随机模式(AI 自由配参数)
|
||||
返回 {candidates:[...], meta:{...}};candidates 已排序(未过滤在前、final_score 降序)
|
||||
"""
|
||||
brief = (brief or "").strip()
|
||||
if not brief:
|
||||
raise ValueError("需要输入场景诉求(1-2 句话)")
|
||||
|
||||
matrix = matrix or load_matrix()
|
||||
lib = lib or load_library()
|
||||
gen = generation_cfg(matrix)
|
||||
n = int(n or gen.get("candidates_per_round") or 30)
|
||||
max_per_layer = int(gen.get("max_params_per_layer") or 2)
|
||||
|
||||
if llm is None:
|
||||
from directors.llm import DirectorLLM
|
||||
llm = DirectorLLM(timeout=180)
|
||||
if not getattr(llm, "available", True):
|
||||
raise RuntimeError("LLM 未配置(请先在设置中配置大模型 Key)")
|
||||
|
||||
hard, soft, props, scenes, positive = _matrix_texts(matrix)
|
||||
param_list = _param_list_text(lib)
|
||||
|
||||
# 参数框架块(按当前参数生成时使用)
|
||||
constraint_block = ""
|
||||
if constraint_selections:
|
||||
_lines = []
|
||||
for s in constraint_selections:
|
||||
nm = s.get("param_name") or s.get("param_id", "")
|
||||
_lines.append(f"· {nm}({s.get('layer_id','')}/{s.get('param_id','')})= {s.get('value','')}")
|
||||
if _lines:
|
||||
constraint_block = (
|
||||
"\n【必须遵守的参数框架(导演已定,这是「做法」)】\n"
|
||||
+ "\n".join(_lines)
|
||||
+ "\n每条候选都**必须**使用这些参数值,在此框架内编不同的点子;"
|
||||
"未列出的参数可以自由发挥以拉开差异。\n"
|
||||
)
|
||||
|
||||
# 分批生成(单批 ≤ batch_size 条)——避免长输出被 max_tokens 截断导致整批丢弃
|
||||
batch_size = max(5, int(gen.get("batch_size") or 15))
|
||||
items: list = []
|
||||
for start in range(0, n, batch_size):
|
||||
bn = min(batch_size, n - len(items))
|
||||
if bn <= 0:
|
||||
break
|
||||
p = _TMPL.format(
|
||||
brief=brief, param_list=param_list, hard_list=hard,
|
||||
soft_list=soft, props=props, scenes=scenes, positive_list=positive, n=bn,
|
||||
)
|
||||
if constraint_block:
|
||||
p += constraint_block
|
||||
if items:
|
||||
p += f"\n\n(前面已给出 {len(items)} 条,本批 **必须不同**,不要重复前面的创意。)"
|
||||
log.info("生成候选 第 %d 批(%d 条)…", start // batch_size + 1, bn)
|
||||
raw = llm.chat(p, system=_SYSTEM, temperature=0.9, max_tokens=8000)
|
||||
got = parse_json_lenient(llm, raw)
|
||||
if isinstance(got, dict):
|
||||
got = got.get("candidates") or got.get("combos") or got.get("data") or []
|
||||
if not isinstance(got, list):
|
||||
log.warning(" 第 %d 批解析失败(输出 %d 字符),跳过", start // batch_size + 1, len(raw or ""))
|
||||
continue
|
||||
items.extend(got)
|
||||
|
||||
if not items:
|
||||
raise RuntimeError("LLM 未返回有效候选列表(可能是输出被截断,请减少条数后重试)")
|
||||
|
||||
constraints = constraints_from_matrix(matrix)
|
||||
candidates: list[ComboCandidate] = []
|
||||
seen: set[str] = set()
|
||||
for i, it in enumerate(items):
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
sels = _resolve_selections(it.get("selections") or [], lib, max_per_layer)
|
||||
if len(sels) < 2:
|
||||
continue # 少于 2 个参数无法计算缺口(照原文校验)
|
||||
one = (it.get("one_liner") or "").strip()
|
||||
key = one or "|".join(f"{s.param_id}:{s.value}" for s in sels)
|
||||
if key in seen:
|
||||
continue # 去重
|
||||
seen.add(key)
|
||||
|
||||
gap = calc_gap(sels)
|
||||
# 关键联想点:取强度最高的 2 个 + 最低的 1 个(两端并置 = 反差所在)
|
||||
_ss = sorted(sels, key=lambda s: -(s.intensity or 0))
|
||||
_kp = []
|
||||
if _ss:
|
||||
_kp.append(_ss[0].cue)
|
||||
if len(_ss) >= 3:
|
||||
_kp.append(_ss[1].cue)
|
||||
_kp.append(_ss[-1].cue)
|
||||
elif len(_ss) == 2:
|
||||
_kp.append(_ss[-1].cue)
|
||||
key_points = [k for k in _kp if k][:3]
|
||||
|
||||
cand = ComboCandidate(
|
||||
cid=f"c{i+1:02d}",
|
||||
one_liner=one,
|
||||
key_points=key_points,
|
||||
selections=sels,
|
||||
gap_score=gap.gap_score,
|
||||
extreme_score=gap.extreme_score,
|
||||
variance=gap.variance,
|
||||
level=gap.level,
|
||||
scene_card=gap.scene_card,
|
||||
needs_soft=[str(x) for x in (it.get("needs_soft") or [])],
|
||||
needs_hard=[str(x) for x in (it.get("needs_hard") or [])],
|
||||
positive_hits=[str(x) for x in (it.get("positive_hits") or [])],
|
||||
props=[str(x) for x in (it.get("props") or [])],
|
||||
scenes=[str(x) for x in (it.get("scenes") or [])],
|
||||
)
|
||||
candidates.append(cand)
|
||||
|
||||
apply_filters(candidates, constraints, rebuild_card=False)
|
||||
kept = [c for c in candidates if not c.filtered]
|
||||
log.info("生成完成:%d 条(硬过滤掉 %d 条)", len(kept), len(candidates) - len(kept))
|
||||
return {
|
||||
"candidates": [c.model_dump() for c in candidates],
|
||||
"meta": {
|
||||
"brief": brief, "requested": n, "generated": len(candidates),
|
||||
"mode": "constrained" if constraint_selections else "random",
|
||||
"constraint_count": len(constraint_selections or []),
|
||||
"kept": len(kept), "filtered": len(candidates) - len(kept),
|
||||
"relaxed_soft": constraints.relaxed_soft_ids(),
|
||||
"at": utcnow(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def candidate_to_card(candidate: ComboCandidate, brief: str = "",
|
||||
title: str = "", matrix: Optional[dict] = None,
|
||||
inject_mode: str = "reference"):
|
||||
"""候选 → 创意卡(入库用)"""
|
||||
from gap_engine.models import CreativeCard
|
||||
from gap_engine.gap_calculator import matrix_snapshot
|
||||
m = matrix or load_matrix()
|
||||
return CreativeCard(
|
||||
title=title or (candidate.one_liner[:30] or brief[:30]),
|
||||
one_liner=candidate.one_liner,
|
||||
brief=brief,
|
||||
selections=candidate.selections,
|
||||
props=candidate.props, scenes=candidate.scenes,
|
||||
gap_score=candidate.gap_score, variance=candidate.variance,
|
||||
level=candidate.level, scene_card=candidate.scene_card,
|
||||
positive_hits=candidate.positive_hits,
|
||||
matrix_snapshot=matrix_snapshot(m),
|
||||
inject_mode=inject_mode,
|
||||
status="selected",
|
||||
created_at=utcnow(), updated_at=utcnow(),
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# AI 参数预选(Tab① 用:先给基础搭配,人再微调)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
_PRESELECT_SYSTEM = """你是短剧/广告导演的「参数预选助手」。
|
||||
|
||||
导演不想从上百个参数里一个个挑。你要根据他的场景诉求 + 他设的矩阵边界,
|
||||
直接给出一套**可直接开拍的基础参数搭配**(每层 1-2 个参数),他再在你的基础上微调。
|
||||
|
||||
要求:
|
||||
1. 只能从给定参数清单里取值,不得自创。
|
||||
2. 每层选 1-2 个参数(不必每层都选,但要覆盖叙事、表达、视觉、声音这几个主干)。
|
||||
3. 目标是「反差较大但拍得出来」——不追求安全,也不故意猎奇。
|
||||
4. 必须尊重矩阵边界:硬违禁绝不涉及;未放开的观众反感项避开;向已勾选的正向价值倾斜。
|
||||
5. rationale 用一句人话说清"这套搭配想干嘛"。
|
||||
|
||||
【三套必须拉开截然不同的反差层级(这是重点)】
|
||||
- 方案一「大胆」:整体远离常规——多个参数都取高强度值,让整条片子"质地异样"(如:象征+大特写+长沉默+逆光)。
|
||||
- 方案二「落差」:把常规项与反常规项并置,制造"预期落差"(如:严肃题材 + 儿歌 BGM)。
|
||||
- 方案三「克制」:整体贴近真实,只在一处藏一个小反常,靠细节取胜。
|
||||
三套的**参数选择要明显不同**,让导演一眼能选出方向;不要三套都用同一种思路。
|
||||
|
||||
【输出纪律(务必遵守,否则内容会被截断丢弃)】
|
||||
- rationale 控制在 **40 字以内**。
|
||||
- 每套 selections **每层最多 1 个参数**,总量 5-7 条。
|
||||
- 不要输出任何 JSON 之外的文字、注释、解释。
|
||||
|
||||
输出严格 JSON 数组。"""
|
||||
|
||||
_PRESELECT_TMPL = """【场景诉求】
|
||||
{brief}
|
||||
|
||||
【六层参数清单(只能从中取值)】
|
||||
{param_list}
|
||||
|
||||
【硬违禁(绝不涉及)】
|
||||
{hard_list}
|
||||
|
||||
【观众反感项(✓=已放开,可用来制造反差;未放开=避开)】
|
||||
{soft_list}
|
||||
|
||||
【希望倾斜的正向价值】
|
||||
{positive_list}
|
||||
|
||||
请给出 {n} 套**基础搭配方案**(风格拉开差异,让导演一眼能选出方向),
|
||||
输出 JSON 数组:
|
||||
[
|
||||
{{
|
||||
"name": "方案A · 短标题(如:冷眼旁观式)",
|
||||
"rationale": "一句话说明这套搭配的思路与效果",
|
||||
"selections": [
|
||||
{{"layer_id": "narrative", "param_id": "info_release", "value": "结果前置"}},
|
||||
... 每层 1~2 个 ...
|
||||
]
|
||||
}}
|
||||
]"""
|
||||
|
||||
|
||||
def preselect_params(brief: str, matrix: Optional[dict] = None,
|
||||
lib: Optional[ParamLibrary] = None,
|
||||
llm=None, n_schemes: int = 3) -> dict:
|
||||
"""AI 预选:按场景 + 矩阵给 N 套七层参数基础搭配(含缺口分)
|
||||
|
||||
返回 {schemes:[{name, rationale, selections, gap_score, variance, level, scene_card}], meta}
|
||||
"""
|
||||
brief = (brief or "").strip()
|
||||
if not brief:
|
||||
raise ValueError("需要先填写场景诉求(1-2 句话)")
|
||||
|
||||
matrix = matrix or load_matrix()
|
||||
lib = lib or load_library()
|
||||
|
||||
if llm is None:
|
||||
from directors.llm import DirectorLLM
|
||||
llm = DirectorLLM(timeout=180)
|
||||
if not getattr(llm, "available", True):
|
||||
raise RuntimeError("LLM 未配置(请先在设置中配置大模型 Key)")
|
||||
|
||||
hard, soft, _props, _scenes, positive = _matrix_texts(matrix)
|
||||
prompt = _PRESELECT_TMPL.format(
|
||||
brief=brief, param_list=_param_list_text(lib), hard_list=hard,
|
||||
soft_list=soft, positive_list=positive, n=max(1, int(n_schemes)),
|
||||
)
|
||||
log.info("AI 预选参数…(场景: %s)", brief[:40])
|
||||
raw = llm.chat(prompt, system=_PRESELECT_SYSTEM, temperature=0.8, max_tokens=4000)
|
||||
items = parse_json_lenient(llm, raw)
|
||||
if isinstance(items, dict):
|
||||
items = items.get("schemes") or items.get("plans") or items.get("data") or []
|
||||
if not isinstance(items, list):
|
||||
raise RuntimeError("LLM 未返回有效方案")
|
||||
|
||||
gen = generation_cfg(matrix)
|
||||
max_per_layer = int(gen.get("max_params_per_layer") or 2)
|
||||
|
||||
schemes: list[dict] = []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
sels = _resolve_selections(it.get("selections") or [], lib, max_per_layer)
|
||||
if len(sels) < 2:
|
||||
continue
|
||||
g = calc_gap(sels)
|
||||
schemes.append({
|
||||
"name": (it.get("name") or f"方案{len(schemes)+1}")[:40],
|
||||
"rationale": (it.get("rationale") or "").strip(),
|
||||
"selections": [s.model_dump() for s in sels],
|
||||
"gap_score": g.gap_score,
|
||||
"extreme_score": g.extreme_score,
|
||||
"variance": g.variance,
|
||||
"level": g.level,
|
||||
"scene_card": g.scene_card,
|
||||
})
|
||||
|
||||
if not schemes:
|
||||
raise RuntimeError("预选失败:未得到有效搭配")
|
||||
|
||||
schemes.sort(key=lambda s: -(s.get("gap_score") or 0))
|
||||
log.info("预选完成:%d 套(最高缺口 %.1f)", len(schemes), schemes[0]["gap_score"])
|
||||
return {"schemes": schemes, "meta": {"brief": brief, "count": len(schemes), "at": utcnow()}}
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
gap_engine.gap_calculator — 缺口计算 + 联想卡 + 矩阵过滤/加权
|
||||
|
||||
缺口算法(照 GapEngine 原文口径,未改):
|
||||
intensities = 被选中参数的 intensity 列表(每层选 0-2 个,不选=中性,不进计算)
|
||||
variance = statistics.variance(intensities)
|
||||
gap_score = min(100, variance * 400) # 理论最大 0.25 × 400 = 100
|
||||
high_count = intensity >= 0.6 的个数
|
||||
low_count = intensity <= 0.3 的个数
|
||||
scene_card = 按层序拼接各值的 cue + 依方差给「缺口显著/中等/微弱」总结
|
||||
|
||||
过滤/加权(原文设计了模块但无代码,此处实现):
|
||||
硬过滤 = 候选涉及 🔴 锁定项 → 淘汰
|
||||
软风险 = 候选需要放开某 🟡 项但用户未放开 → 标记(不淘汰)
|
||||
正向加权= 命中正向项 → final_score = gap_score + Σ bonus
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_SCRIPTS = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
|
||||
from lib.logger import get_logger # noqa: E402
|
||||
from gap_engine.models import ( # noqa: E402
|
||||
ComboCandidate, CreativeConstraints, GapResult, Selection,
|
||||
)
|
||||
|
||||
log = get_logger("gap_engine")
|
||||
|
||||
# 层顺序(联想卡按此顺序拼接,照原文)
|
||||
LAYER_ORDER = ["narrative", "rhetoric", "visual", "motion", "lighting", "performance", "sound"]
|
||||
|
||||
# 缺口强弱阈值(照原文:variance > 0.06 显著 / > 0.03 中等 / 否则微弱)
|
||||
LEVEL_HIGH = 0.06
|
||||
LEVEL_MID = 0.03
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 缺口计算(照原文)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def calc_gap(selections: list[Selection]) -> GapResult:
|
||||
"""一次选择的缺口计算"""
|
||||
intensities = [s.intensity for s in selections]
|
||||
if len(intensities) < 2:
|
||||
variance = 0.0
|
||||
else:
|
||||
variance = statistics.variance(intensities)
|
||||
|
||||
gap_score = min(100.0, variance * 400)
|
||||
|
||||
# 极端度:各选中值偏离"中性预期(0.5)"的平均程度 —— 捕捉"整体异质"型反差
|
||||
# (全取高反常规值时方差小但异质度高,原文方差法会漏掉这类)
|
||||
if intensities:
|
||||
extreme_score = min(100.0, (sum(abs(i - 0.5) for i in intensities) / len(intensities)) * 200)
|
||||
else:
|
||||
extreme_score = 0.0
|
||||
high_count = sum(1 for i in intensities if i >= 0.6)
|
||||
low_count = sum(1 for i in intensities if i <= 0.3)
|
||||
|
||||
card, level = build_scene_card(selections)
|
||||
return GapResult(
|
||||
selections=list(selections),
|
||||
variance=round(variance, 4),
|
||||
gap_score=round(gap_score, 1),
|
||||
extreme_score=round(extreme_score, 1),
|
||||
high_count=high_count,
|
||||
low_count=low_count,
|
||||
scene_card=card,
|
||||
level=level,
|
||||
)
|
||||
|
||||
|
||||
def build_scene_card(selections: list[Selection]) -> tuple[str, str]:
|
||||
"""把选中的参数值翻译成一段连贯的画面描述(照原文),返回 (卡片文本, 强弱级别)"""
|
||||
sorted_sels = sorted(
|
||||
selections,
|
||||
key=lambda s: LAYER_ORDER.index(s.layer_id) if s.layer_id in LAYER_ORDER else 99,
|
||||
)
|
||||
body = "。".join(s.cue for s in sorted_sels if s.cue)
|
||||
if body and not body.endswith("。"):
|
||||
body += "。"
|
||||
|
||||
intensities = [s.intensity for s in selections]
|
||||
variance = statistics.variance(intensities) if len(intensities) >= 2 else 0.0
|
||||
if variance > LEVEL_HIGH:
|
||||
level = "显著"
|
||||
summary = ("\n\n【缺口显著】这个组合的参数预期强度差异很大,"
|
||||
"观众会在「预期」和「实际」之间产生强烈的认知落差。")
|
||||
elif variance > LEVEL_MID:
|
||||
level = "中等"
|
||||
summary = ("\n\n【缺口中等】有反差但不极端,"
|
||||
"适合需要平衡真实感和吸引力的场景。")
|
||||
else:
|
||||
level = "微弱"
|
||||
summary = ("\n\n【缺口微弱】参数预期一致,画面和谐但可能缺乏冲击力。"
|
||||
"如果想制造反差,需要调整某个参数的方向。")
|
||||
return body + summary, level
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 矩阵过滤 + 正向加权(新实现)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def apply_filters(candidates: list[ComboCandidate],
|
||||
constraints: CreativeConstraints,
|
||||
rebuild_card: bool = True) -> list[ComboCandidate]:
|
||||
"""对候选做硬过滤 + 软风险标记 + 正向加权,并排序(未过滤在前,final_score 降序)"""
|
||||
relaxed = set(constraints.relaxed_soft_ids())
|
||||
locked = set(constraints.locked_hard_ids())
|
||||
hard_name = {h["id"]: h.get("name", h["id"]) for h in constraints.negative_hard}
|
||||
soft_name = {s["id"]: s.get("name", s["id"]) for s in constraints.negative_soft}
|
||||
bonus_map = {p["id"]: float(p.get("bonus", 8) or 8) for p in constraints.positive}
|
||||
pos_name = {p["id"]: p.get("name", p["id"]) for p in constraints.positive}
|
||||
|
||||
for c in candidates:
|
||||
# ① 硬过滤:涉及 🔴 锁定项 → 淘汰
|
||||
hard_hit = [h for h in (c.needs_hard or []) if h in locked]
|
||||
if hard_hit:
|
||||
c.filtered = True
|
||||
c.filter_reason = "涉及硬违禁:" + "、".join(hard_name.get(h, h) for h in hard_hit)
|
||||
c.final_score = 0.0
|
||||
continue
|
||||
|
||||
# ② 软风险:需要放开 🟡 项但未放开 → 标记(不淘汰)
|
||||
c.risks = []
|
||||
for s in (c.needs_soft or []):
|
||||
if s in relaxed:
|
||||
continue
|
||||
c.risks.append({
|
||||
"kind": "soft", "id": s, "name": soft_name.get(s, s),
|
||||
"reason": f'需要放开"{soft_name.get(s, s)}"才能拍(当前未放开)',
|
||||
})
|
||||
|
||||
# ③ 正向加权
|
||||
c.bonus = sum(bonus_map.get(p, 0) for p in (c.positive_hits or []))
|
||||
c.positive_hits = [p for p in (c.positive_hits or []) if p in pos_name]
|
||||
c.final_score = round(max(c.gap_score or 0, c.extreme_score or 0) + c.bonus, 1)
|
||||
|
||||
# ④ 重算联想卡(若调用方未生成)
|
||||
if rebuild_card and not c.scene_card and c.selections:
|
||||
card, level = build_scene_card(c.selections)
|
||||
c.scene_card = card
|
||||
c.level = c.level or level
|
||||
|
||||
candidates.sort(key=lambda x: (x.filtered, -(x.final_score or 0)))
|
||||
return candidates
|
||||
|
||||
|
||||
def matrix_snapshot(matrix: dict) -> dict:
|
||||
"""记录本次使用的矩阵(含放开了哪些 🟡)——存进创意卡供追溯"""
|
||||
neg = matrix.get("negative") or {}
|
||||
soft = neg.get("soft") or []
|
||||
pos = matrix.get("positive") or []
|
||||
return {
|
||||
"generation": matrix.get("generation") or {},
|
||||
"negative_hard": [h.get("id") for h in (neg.get("hard") or [])],
|
||||
"negative_soft_relaxed": [s.get("id") for s in soft if s.get("relaxed")],
|
||||
"negative_soft_excluded": [s.get("id") for s in soft if not s.get("relaxed")],
|
||||
"positive": [p.get("id") for p in pos if p.get("enabled", True)],
|
||||
"preset": matrix.get("active_preset", ""),
|
||||
"at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
gap_engine.library — 参数库与矩阵配置的读写
|
||||
|
||||
数据来源(都可直接编辑,改动即时生效):
|
||||
09_ave/config/param_library.yaml 六层参数库(6层/38参数/177值)
|
||||
09_ave/config/creative_matrix.yaml 矩阵配置(负面/正向/道具场景/生成参数)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
_SCRIPTS = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
|
||||
from lib.logger import get_logger # noqa: E402
|
||||
from gap_engine.models import ParamLibrary, CreativeConstraints # noqa: E402
|
||||
|
||||
log = get_logger("gap_engine")
|
||||
|
||||
_AVE_DIR = _SCRIPTS.parent
|
||||
LIB_PATH = _AVE_DIR / "config" / "param_library.yaml"
|
||||
MATRIX_PATH = _AVE_DIR / "config" / "creative_matrix.yaml"
|
||||
|
||||
# 简易缓存(按文件 mtime 失效)
|
||||
_cache: dict = {}
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> dict:
|
||||
try:
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except Exception as e:
|
||||
log.warning("读取 %s 失败: %s", path.name, e)
|
||||
return {}
|
||||
|
||||
|
||||
def load_library(use_cache: bool = True) -> ParamLibrary:
|
||||
"""加载六层参数库(按 mtime 自动失效缓存)"""
|
||||
key = "library"
|
||||
try:
|
||||
mtime = LIB_PATH.stat().st_mtime
|
||||
except Exception:
|
||||
mtime = 0
|
||||
if use_cache and _cache.get(key, {}).get("mtime") == mtime:
|
||||
return _cache[key]["obj"]
|
||||
d = _load_yaml(LIB_PATH)
|
||||
lib = ParamLibrary(**d) if d.get("layers") else ParamLibrary(layers=[])
|
||||
_cache[key] = {"mtime": mtime, "obj": lib}
|
||||
return lib
|
||||
|
||||
|
||||
def load_matrix(use_cache: bool = True) -> dict:
|
||||
"""加载矩阵配置(原始 dict,含 generation / negative / positive / props / scenes / presets)"""
|
||||
key = "matrix"
|
||||
try:
|
||||
mtime = MATRIX_PATH.stat().st_mtime
|
||||
except Exception:
|
||||
mtime = 0
|
||||
if use_cache and _cache.get(key, {}).get("mtime") == mtime:
|
||||
return _cache[key]["obj"]
|
||||
d = _load_yaml(MATRIX_PATH)
|
||||
_cache[key] = {"mtime": mtime, "obj": d}
|
||||
return d
|
||||
|
||||
|
||||
def save_matrix(data: dict) -> dict:
|
||||
"""保存矩阵配置(前端 ②③④ 标签页的勾选结果)"""
|
||||
data = dict(data or {})
|
||||
data["updated"] = __import__("datetime").date.today().isoformat()
|
||||
MATRIX_PATH.write_text(
|
||||
yaml.safe_dump(data, allow_unicode=True, sort_keys=False, width=100),
|
||||
encoding="utf-8")
|
||||
_cache.pop("matrix", None)
|
||||
log.info("矩阵配置已保存")
|
||||
return load_matrix(use_cache=False)
|
||||
|
||||
|
||||
def save_library_text(text: str) -> dict:
|
||||
"""保存参数库(整份 YAML 文本;供高级编辑用)"""
|
||||
parsed = yaml.safe_load(text)
|
||||
if not parsed or not parsed.get("layers"):
|
||||
raise ValueError("参数库格式错误:缺少 layers")
|
||||
LIB_PATH.write_text(text, encoding="utf-8")
|
||||
_cache.pop("library", None)
|
||||
lib = load_library(use_cache=False)
|
||||
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": len(lib.layers), "params": n_param, "values": n_val}
|
||||
|
||||
|
||||
def constraints_from_matrix(matrix: Optional[dict] = None) -> CreativeConstraints:
|
||||
"""矩阵 dict → CreativeConstraints(供过滤/加权使用)"""
|
||||
m = matrix or load_matrix()
|
||||
neg = m.get("negative") or {}
|
||||
return CreativeConstraints(
|
||||
negative_hard=neg.get("hard") or [],
|
||||
negative_soft=neg.get("soft") or [],
|
||||
positive=m.get("positive") or [],
|
||||
props=m.get("props") or [],
|
||||
scenes=m.get("scenes") or [],
|
||||
)
|
||||
|
||||
|
||||
def generation_cfg(matrix: Optional[dict] = None) -> dict:
|
||||
m = matrix or load_matrix()
|
||||
return m.get("generation") or {}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
gap_engine.models — 数据模型(照 GapEngine 原文,含创意卡扩展)
|
||||
|
||||
原文 7 个类:ParamValue / Param / Layer / ParamLibrary / Selection / GapResult / CreativeConstraints
|
||||
扩展:ComboCandidate(AI 生成的候选组合)、CreativeCard(入库的创意卡)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 参数库(照原文)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class ParamValue(BaseModel):
|
||||
"""单个参数值:值名 + 预期强度 + 画面联想描述"""
|
||||
value: str
|
||||
intensity: float = 0.5
|
||||
cue: str = ""
|
||||
|
||||
|
||||
class Param(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
values: list[ParamValue] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Layer(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
params: list[Param] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ParamLibrary(BaseModel):
|
||||
layers: list[Layer] = Field(default_factory=list)
|
||||
|
||||
def find_value(self, layer_id: str, param_id: str, value: str) -> Optional[ParamValue]:
|
||||
for L in self.layers:
|
||||
if L.id != layer_id:
|
||||
continue
|
||||
for p in L.params:
|
||||
if p.id != param_id:
|
||||
continue
|
||||
for v in p.values:
|
||||
if v.value == value:
|
||||
return v
|
||||
return None
|
||||
|
||||
def param_name(self, layer_id: str, param_id: str) -> str:
|
||||
for L in self.layers:
|
||||
if L.id == layer_id:
|
||||
for p in L.params:
|
||||
if p.id == param_id:
|
||||
return p.name
|
||||
return param_id
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 选择与缺口(照原文)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class Selection(BaseModel):
|
||||
"""一次选择:某个参数取某个值"""
|
||||
layer_id: str
|
||||
param_id: str
|
||||
param_name: str = ""
|
||||
value: str
|
||||
intensity: float
|
||||
cue: str = ""
|
||||
|
||||
|
||||
class GapResult(BaseModel):
|
||||
"""缺口计算结果(字段口径照原文)"""
|
||||
selections: list[Selection] = Field(default_factory=list)
|
||||
variance: float = 0.0
|
||||
gap_score: float = 0.0 # 0-100(方差 × 400 归一,原文口径:落差型反差)
|
||||
extreme_score: float = 0.0 # 0-100(平均偏离中性 0.5,补足"整体异质"型反差)
|
||||
high_count: int = 0 # intensity >= 0.6
|
||||
low_count: int = 0 # intensity <= 0.3
|
||||
scene_card: str = "" # 画面联想卡
|
||||
level: str = "" # 显著 / 中等 / 微弱
|
||||
|
||||
|
||||
class CreativeConstraints(BaseModel):
|
||||
"""矩阵配置(负面/正向/道具场景)—— 本文档不含生成参数"""
|
||||
negative_hard: list[dict] = Field(default_factory=list)
|
||||
negative_soft: list[dict] = Field(default_factory=list)
|
||||
positive: list[dict] = Field(default_factory=list)
|
||||
props: list[dict] = Field(default_factory=list)
|
||||
scenes: list[dict] = Field(default_factory=list)
|
||||
|
||||
def relaxed_soft_ids(self) -> list[str]:
|
||||
return [s["id"] for s in self.negative_soft if s.get("relaxed")]
|
||||
|
||||
def locked_hard_ids(self) -> list[str]:
|
||||
return [s["id"] for s in self.negative_hard if s.get("locked")]
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 候选组合(AI 生成,扩展)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class ComboCandidate(BaseModel):
|
||||
"""一条候选创意(= 一次完整选择 + 缺口分 + 一句话联想 + 风险标记)"""
|
||||
cid: str = "" # 候选 id(批次内唯一)
|
||||
one_liner: str = "" # 核心产物:一句话联想(导演据此脑补)
|
||||
selections: list[Selection] = Field(default_factory=list)
|
||||
key_points: list[str] = Field(default_factory=list) # 关键联想点(不展开就能判断)
|
||||
gap_score: float = 0.0
|
||||
extreme_score: float = 0.0 # 整体异质度(全高强度也可能是强反差)
|
||||
variance: float = 0.0
|
||||
level: str = ""
|
||||
scene_card: str = ""
|
||||
risks: list[dict] = Field(default_factory=list) # [{kind:'soft'|'hard', id, name, reason}]
|
||||
needs_soft: list[str] = Field(default_factory=list) # LLM 标注:本条需要放开哪些 🟡 项(id)
|
||||
needs_hard: list[str] = Field(default_factory=list) # LLM 标注:本条涉及哪些 🔴 项(id)→ 命中即淘汰
|
||||
positive_hits: list[str] = Field(default_factory=list) # 命中的正向项 id
|
||||
bonus: float = 0.0 # 正向加权分
|
||||
final_score: float = 0.0 # gap_score + bonus
|
||||
props: list[str] = Field(default_factory=list) # 道具(名称)
|
||||
scenes: list[str] = Field(default_factory=list) # 场景变量(名称)
|
||||
filtered: bool = False # 是否被硬过滤淘汰
|
||||
filter_reason: str = ""
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 创意卡(入库,扩展)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
INJECT_MODES = ("strict", "reference", "idea_only")
|
||||
|
||||
|
||||
class CreativeCard(BaseModel):
|
||||
"""创意卡 = 入创意库的一条创意(可被分镜/口播/脚本流程引用)"""
|
||||
id: str = ""
|
||||
title: str = "" # 主题(一句话)
|
||||
one_liner: str = "" # 一句话联想
|
||||
brief: str = "" # 原始输入的 1-2 句话
|
||||
selections: list[Selection] = Field(default_factory=list)
|
||||
props: list[str] = Field(default_factory=list)
|
||||
scenes: list[str] = Field(default_factory=list)
|
||||
gap_score: float = 0.0
|
||||
variance: float = 0.0
|
||||
level: str = ""
|
||||
scene_card: str = ""
|
||||
positive_hits: list[str] = Field(default_factory=list)
|
||||
matrix_snapshot: dict = Field(default_factory=dict) # 本次矩阵(含放开了哪些🟡)
|
||||
inject_mode: str = "reference" # strict 严格约束 / reference 参考 / idea_only 仅创意
|
||||
status: str = "draft" # draft 草稿 / selected 已选 / used 已用于
|
||||
used_by: list[dict] = Field(default_factory=list) # [{flow, run_id, at}]
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
|
||||
|
||||
def utcnow() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
gap_engine.store — 创意卡存储(创意库的持久化)
|
||||
|
||||
位置:agent-local/tools/ave/creatives/{id}.json(运行时数据,非人工编辑)
|
||||
一张卡一个文件,便于 git 之外的本机私有积累与迁移。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_SCRIPTS = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
|
||||
from lib.logger import get_logger # noqa: E402
|
||||
from gap_engine.models import CreativeCard, utcnow # noqa: E402
|
||||
|
||||
log = get_logger("gap_engine")
|
||||
|
||||
_AGENT_LOCAL = Path(__import__("os").environ.get(
|
||||
"AGENT_LOCAL", Path.home() / "workbuddy-agent-os" / "agent-local"))
|
||||
_DIR = _AGENT_LOCAL / "tools" / "ave" / "creatives"
|
||||
|
||||
|
||||
def _ensure_dir() -> Path:
|
||||
_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return _DIR
|
||||
|
||||
|
||||
def _path(cid: str) -> Path:
|
||||
return _ensure_dir() / f"{cid}.json"
|
||||
|
||||
|
||||
def create(card: CreativeCard) -> dict:
|
||||
"""新建创意卡(自动生成 id 与时间戳)"""
|
||||
if not card.id:
|
||||
card.id = "cre_" + uuid.uuid4().hex[:10]
|
||||
now = utcnow()
|
||||
card.created_at = card.created_at or now
|
||||
card.updated_at = now
|
||||
_path(card.id).write_text(
|
||||
json.dumps(card.model_dump(), ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
log.info("创意卡入库: %s(%s)", card.id, card.title[:30])
|
||||
return card.model_dump()
|
||||
|
||||
|
||||
def get(cid: str) -> Optional[dict]:
|
||||
p = _path(cid)
|
||||
if not p.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
log.warning("读取创意卡失败 %s: %s", cid, e)
|
||||
return None
|
||||
|
||||
|
||||
def update(cid: str, patch: dict) -> Optional[dict]:
|
||||
cur = get(cid)
|
||||
if not cur:
|
||||
return None
|
||||
cur.update({k: v for k, v in (patch or {}).items() if k not in ("id", "created_at")})
|
||||
cur["updated_at"] = utcnow()
|
||||
_path(cid).write_text(json.dumps(cur, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
return cur
|
||||
|
||||
|
||||
def delete(cid: str) -> bool:
|
||||
p = _path(cid)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def list_cards(q: str = "", status: str = "", inject_mode: str = "",
|
||||
limit: int = 200, sort: str = "updated_desc") -> list[dict]:
|
||||
"""创意卡列表(支持关键词/状态/注入模式筛选)"""
|
||||
out: list[dict] = []
|
||||
for f in _ensure_dir().glob("cre_*.json"):
|
||||
try:
|
||||
d = json.loads(f.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
if status and d.get("status") != status:
|
||||
continue
|
||||
if inject_mode and d.get("inject_mode") != inject_mode:
|
||||
continue
|
||||
if q:
|
||||
blob = " ".join(str(d.get(k, "")) for k in ("title", "one_liner", "brief", "scene_card"))
|
||||
blob += " " + " ".join(d.get("props") or []) + " " + " ".join(d.get("scenes") or [])
|
||||
if q not in blob:
|
||||
continue
|
||||
out.append(d)
|
||||
key = {
|
||||
"updated_desc": lambda d: d.get("updated_at", ""),
|
||||
"gap_desc": lambda d: -(d.get("gap_score") or 0),
|
||||
"created_desc": lambda d: d.get("created_at", ""),
|
||||
}.get(sort, lambda d: d.get("updated_at", ""))
|
||||
reverse = sort != "gap_desc"
|
||||
out.sort(key=key, reverse=reverse)
|
||||
return out[:limit]
|
||||
|
||||
|
||||
def stats() -> dict:
|
||||
cards = list_cards(limit=10000)
|
||||
by_status: dict[str, int] = {}
|
||||
by_mode: dict[str, int] = {}
|
||||
for c in cards:
|
||||
by_status[c.get("status", "draft")] = by_status.get(c.get("status", "draft"), 0) + 1
|
||||
by_mode[c.get("inject_mode", "reference")] = by_mode.get(c.get("inject_mode", "reference"), 0) + 1
|
||||
gaps = [c.get("gap_score") or 0 for c in cards]
|
||||
return {
|
||||
"total": len(cards),
|
||||
"by_status": by_status,
|
||||
"by_inject_mode": by_mode,
|
||||
"avg_gap": round(sum(gaps) / len(gaps), 1) if gaps else 0,
|
||||
}
|
||||
Reference in New Issue
Block a user