feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 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:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -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,
}