Compare commits

..

3 Commits

Author SHA1 Message Date
自动化测试Agent e92004fca3 chore: 触发CI(auto-format后重跑完整流水线)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 39s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 58s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m31s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m5s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m47s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m24s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m38s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m42s
AI Code Review / AI Code Review (pull_request) Successful in 6m57s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 13m18s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m5s
CI/CD Pipeline / CI Gate (pull_request) Blocked by required conditions
CI/CD Pipeline / Integration Tests (pull_request) Has started running
2026-10-12 14:00:16 +08:00
CI Bot b1846207cc style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m15s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m22s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m15s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m21s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 4m31s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m21s
AI Code Review / AI Code Review (pull_request) Successful in 7m29s
2026-10-12 05:35:31 +00:00
自动化测试Agent 451ae99242 feat(ditto): 模型配置改自由文本支持ep-接入点ID,model_options从DB读取
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
AI Code Review / AI Code Review (pull_request) Successful in 6m51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m53s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m55s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 47s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m5s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m56s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m30s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m52s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 6m29s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 12m33s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
- 删除硬编码 MODEL_OPTIONS 及白名单校验,改为自由文本校验:
  非空、长度≤200、仅允许字母/数字/连字符/下划线
- model_options 不再返回硬编码列表,改从 system_config 的
  ditto_emotion_model_options(JSON 数组)读取,未配置/非法返回空数组
- _call_llm 的 model 参数现状即为直传配置值,无需转换
- 测试:模型校验改自由文本用例(ep-ID/裸模型名/下划线放行,
  空/超长/非法字符拒绝),新增 TestAdminRouteModelOptions 5 用例
2026-10-12 13:25:12 +08:00
5 changed files with 109 additions and 112 deletions
+1 -1
View File
@@ -1 +1 @@
trigger staging deploy for 3fa1fbf0 (MOCK_STORYBOARD fix) at 2026-10-12 13:40:16
trigger staging deploy for bec75a07 (viral-video error isolation) at 2026-10-11 19:39:29
+29 -9
View File
@@ -5,10 +5,14 @@
- ditto_emotion_enabled / ditto_emotion_model / ditto_emotion_temperature
- ditto_emotion_prompt / ditto_blend_frames / doubao_api_key
注意:doubao_api_key 属于密钥,GET 只返回脱敏占位,绝不返回明文。
ditto_emotion_model 为自由文本(支持火山方舟 ep- 接入点 ID),
推荐选项 model_options 从 system_config 的 ditto_emotion_model_options(JSON 数组)读取。
"""
from __future__ import annotations
import json
import re
from typing import Any
from fastapi import APIRouter, Depends
@@ -31,12 +35,6 @@ router = APIRouter(
dependencies=[Depends(_verify_internal_api_key)],
)
MODEL_OPTIONS = [
"doubao-seed-2-1-lite-250915",
"doubao-seed-2-1-pro-250915",
"deepseek-v3",
]
# key → (类型, 分类)
_WHITELIST: dict[str, str] = {
"ditto_emotion_enabled": SETTING_TYPE_BOOL,
@@ -69,6 +67,23 @@ def _default_value(key: str) -> Any:
return getattr(_settings(), key)
def _model_options_from_db(service) -> list[str]:
"""模型推荐选项:从 system_config 读 ditto_emotion_model_options(JSON 数组字符串)。
未配置 / 非法 JSON / 非数组 JSON 均返回空数组;数组元素统一转字符串。
"""
raw = service.get_config("ditto_emotion_model_options", "") or ""
if not str(raw).strip():
return []
try:
data = json.loads(raw)
except (TypeError, ValueError):
return []
if not isinstance(data, list):
return []
return [str(x) for x in data if isinstance(x, (str, int, float))]
def _build_config_item(key: str) -> dict[str, Any]:
service = get_system_config_service()
if key in _SECRET_KEYS:
@@ -90,7 +105,7 @@ def _build_config_item(key: str) -> dict[str, Any]:
}
item["value"] = service.get_config(key, _default_value(key))
if key == "ditto_emotion_model":
item["model_options"] = list(MODEL_OPTIONS)
item["model_options"] = _model_options_from_db(service)
return item
@@ -126,8 +141,13 @@ def _validate_value(key: str, value: Any) -> Any:
raise ValueError(f"{key} 必须是字符串")
if key == "ditto_emotion_prompt" and value.strip() and "{文案}" not in value:
raise ValueError("提示词必须包含 {文案} 占位符")
if key == "ditto_emotion_model" and value not in MODEL_OPTIONS:
raise ValueError(f"模型必须是以下之一:{', '.join(MODEL_OPTIONS)}")
if key == "ditto_emotion_model":
if not value.strip():
raise ValueError(f"{key} 不能为空字符串")
if len(value) > 200:
raise ValueError(f"{key} 长度不能超过 200 字符")
if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
raise ValueError(f"{key} 只能包含字母、数字、连字符和下划线")
if key == "doubao_api_key" and not value.strip():
raise ValueError(f"{key} 不能为空字符串")
return value
@@ -344,16 +344,6 @@ interface Storyboard {
}
/** 兼容旧 copy_result(final_copy/title/scenes)→ 新 Storyboard 结构 */
function durationForShots(shots: ShotScript[] | undefined): number {
if (!shots || shots.length === 0) return 15
let maxEnd = 0
for (const s of shots) {
const m = (s.time_range || "").match(/(\d+)-(\d+)秒?/)
if (m) maxEnd = Math.max(maxEnd, parseInt(m[2], 10))
}
return maxEnd > 0 ? maxEnd : 15
}
function copyResultToStoryboard(cr: CopyResult | null | undefined): Storyboard | null {
if (!cr) return null
// 新结构:含 shots/overview 字段直接用
@@ -1553,10 +1543,7 @@ const ViralVideoPage: React.FC = () => {
</div>
<div className="vv-sb-doc vv-streaming-doc">
<pre className="vv-streaming-pre">
{task.scriptStreamText
.replace(/<\/?[a-zA-Z_][\w:-]*(?:\s[^>]*)?>/g, "")
.replace(/\s{3,}/g, "\n\n")
.trim()}
{task.scriptStreamText}
<span className="vv-streaming-caret" aria-hidden="true" />
</pre>
</div>
@@ -1566,46 +1553,16 @@ const ViralVideoPage: React.FC = () => {
</div>
)
}
// P0修复:真实分镜数据渲染。移除MOCK_STORYBOARD硬编码兜底——旧逻辑isNewStructure判定过严
// (shots≥2 + scene_and_lighting非空 + duration非空)时才用真实数据,否则回退到写死的"实木餐桌"
// 导致任何字段缺失时用户看到与上传图片完全无关的MOCK卡片。改为:只要有shots或voiceover就用真实数据,
// 缺失字段用默认值补齐,永远不回退到MOCK(MOCK仅在开发预览时使用,生产绝不能出现)。
// 后端新结构尚未就绪时,用 MOCK_STORYBOARD 做预览展示;新结构到了之后优先用真实数据
const sb: Storyboard | null = (() => {
if (!task.storyboard) return null
const s = task.storyboard
// 有任何真实内容(shots≥1 或 有口播文案)就渲染真实数据
const hasRealContent =
(Array.isArray(s.shots) && s.shots.length >= 1) ||
(s.voiceover_script && s.voiceover_script.trim().length > 0)
if (!hasRealContent) return null
// 补齐可能缺失的字段,避免渲染崩溃
return {
...s,
overview: {
theme: s.overview?.theme || "AI 分镜脚本",
total_duration: s.overview?.total_duration || `${durationForShots(s.shots)}s`,
aspect_ratio: s.overview?.aspect_ratio || "9:16",
},
scene_and_lighting: s.scene_and_lighting || "基于产品场景的真实画面",
shots:
s.shots && s.shots.length > 0
? s.shots
: [
{
time_range: "0-15s",
shot_type_angle_movement: "",
scene_and_dialogue: s.voiceover_script || "",
action_details: "",
audio_bgm: "轻快BGM",
transition: "",
reference_image_index: 0,
},
],
hard_constraints: Array.isArray(s.hard_constraints) ? s.hard_constraints : [],
negative_prompts: Array.isArray(s.negative_prompts) ? s.negative_prompts : [],
voiceover_script: s.voiceover_script || "",
copy_display_markdown: s.copy_display_markdown || "",
}
const isNewStructure =
task.storyboard.shots.length >= 2 &&
!!task.storyboard.scene_and_lighting &&
task.storyboard.overview.total_duration.length > 0
if (isNewStructure) return task.storyboard
// 后端还在旧结构:兜底用 mock 展示分镜卡片效果
return MOCK_STORYBOARD
})()
if (
!sb &&
+16 -47
View File
@@ -729,52 +729,21 @@ def _replace_henjin_everywhere(obj: Any) -> Any:
def _fallback_script(job: ViralVideoJob) -> dict:
"""脚本生成失败时的极简兜底脚本。基于image_analysis注入真实商品/门店信息,避免生成脱离用户输入的通用文案。"""
"""脚本生成失败时的极简兜底脚本。"""
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
ratio = getattr(job, "video_ratio", None) or "9:16"
image_analysis = normalize_image_analysis(getattr(job, "image_analysis", None))
default_theme = _determine_theme(image_analysis, getattr(job, "marketing_purpose", "") or "")
base = _empty_copy_result(dur, ratio, theme=default_theme)
# 从 VLM 结果中提取真实商品/门店名称,避免兜底文案完全脱离用户输入
images = image_analysis.get("images", []) if isinstance(image_analysis, dict) else []
_voiceover_map = {
"store": "带你探店!今天来到这家店,环境真的很不错,服务也很到位,推荐大家来体验一下。",
"person": "哈喽,今天给大家分享我的日常,简单舒适又好看,你们觉得怎么样?",
"scene": "带大家感受一下这个空间,氛围感拉满,真的很适合打卡体验。",
"product": "你好,给大家分享一款我最近在用的好物,真的很不错,推荐你们也试试。",
}
images = image_analysis.get("images", [])
_dominant = (images[0].get("type") if images else "product") or "product"
_names = []
_brands = []
for img in images[:3]:
n = (img.get("name") or "").strip()
b = (img.get("brand") or "").strip()
if n and n != "未识别" and n not in _names:
_names.append(n)
if b and b not in _brands:
_brands.append(b)
_name_str = _names[0] if _names else ""
_brand_str = _brands[0] if _brands else ""
_mp = (getattr(job, "marketing_purpose", "") or "").strip()
# 基于真实识别结果构建口播,不再用"这家店""一款好物"等通用表述
if job.user_copy_text:
voiceover = job.user_copy_text
elif _dominant == "store" and _name_str:
voiceover = (
f"今天带大家来{_name_str}看看,{_brand_str + ',' if _brand_str else ''}环境和服务都很赞,推荐来体验!"
)
elif _dominant == "store":
voiceover = "今天带大家探店,这家店的环境和服务都很不错,推荐大家来体验一下。"
elif _dominant == "product" and _name_str:
voiceover = f"给大家分享{_brand_str + '家的' if _brand_str else ''}{_name_str},真心不错,推荐你们试试。"
elif _dominant == "product":
voiceover = "给大家分享一款我最近在用的好物,真的很不错,推荐你们也试试。"
elif _dominant == "person":
voiceover = "哈喽,今天给大家分享我的日常,简单舒适又好看,你们觉得怎么样?"
elif _dominant == "scene" and _name_str:
voiceover = f"带大家感受一下{_name_str},氛围感拉满,真的很适合打卡体验。"
else:
voiceover = "带大家感受一下这个空间,氛围感拉满,真的很适合打卡体验。"
# 口播超长时截断
_max_v = dur * 4
if len(voiceover) > _max_v:
voiceover = voiceover[:_max_v].rstrip(",、。, ") + "。"
voiceover = job.user_copy_text or _voiceover_map.get(_dominant, _voiceover_map["product"])
base["shots"] = [
{
"time_range": f"0-{dur}秒",
@@ -1009,9 +978,9 @@ def _step_script_generation(
return None
# Bug2 fix: 口播字数 + 镜头数量 + 时间轴后校验
_dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
_max_chars = _dur * 4 # 中文口播正常4字/秒,3字太紧凑LLM多次超限触发兜底
_max_chars = _dur * 3 # 每秒最多3字
_voiceover_chars = len(voiceover.strip())
_min_chars = max(10, int(_dur * 2.5))
_min_chars = max(10, int(_dur * 2.2))
if _voiceover_chars > _max_chars:
logger.warning(
"[爆款视频] 口播超长 label=%s voiceover_chars=%d max=%d dur=%ds,将尝试压缩",
@@ -1165,9 +1134,9 @@ def _step_script_generation(
if is_fallback:
return None
_dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
_max_chars = _dur * 4 # 中文口播正常4字/秒
_max_chars = _dur * 3
_voiceover_chars = len(voiceover.strip())
_min_chars = max(10, int(_dur * 2.5))
_min_chars = max(10, int(_dur * 2.2))
if _voiceover_chars > _max_chars:
logger.warning(
"[爆款视频] 口播超长(流式) label=%s voiceover_chars=%d max=%d", label, _voiceover_chars, _max_chars
@@ -1199,8 +1168,8 @@ def _step_script_generation(
deadline = time.time() + 180
# Bug2 fix: 构建字数约束提示,注入到 user prompt
_dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
_max_chars = _dur * 4 # 中文口播正常4字/秒
_min_chars = max(10, int(_dur * 2.5))
_max_chars = _dur * 3
_min_chars = max(10, _dur * 2)
_char_hint = f"口播总字数严格控制在 {_min_chars}~{_max_chars} 字({_dur}秒视频),超长会导致配音失败"
user = user + "\n\n" + _char_hint
@@ -1755,7 +1724,7 @@ def _finalize_fallback_script(job: ViralVideoJob) -> dict:
if not _validate_shot_timeline(shots, dur):
fb["shots"] = _redistribute_timeline(shots, dur)
voice = fb.get("voiceover_script", "") or ""
max_chars = dur * 4
max_chars = dur * 3
if len(voice) > max_chars:
voice = _truncate_voiceover(voice, max_chars)
fb["voiceover_script"] = voice
+54 -3
View File
@@ -276,11 +276,23 @@ class TestAdminRouteValidation:
# 允许留空(使用默认)
assert route._validate_value("ditto_emotion_prompt", "") == ""
def test_validate_model_options(self):
def test_validate_model_free_text(self):
route = self._route()
# 空 / 超长 / 非法字符拒绝
with pytest.raises(ValueError):
route._validate_value("ditto_emotion_model", "gpt-4")
assert route._validate_value("ditto_emotion_model", "deepseek-v3") == "deepseek-v3"
route._validate_value("ditto_emotion_model", " ")
with pytest.raises(ValueError):
route._validate_value("ditto_emotion_model", "a" * 201)
with pytest.raises(ValueError):
route._validate_value("ditto_emotion_model", "模型.带特殊字符")
with pytest.raises(ValueError):
route._validate_value("ditto_emotion_model", "has space")
# ep- 接入点 ID、裸模型名、下划线均放行
assert route._validate_value("ditto_emotion_model", "ep-20260721114705-b568m") == "ep-20260721114705-b568m"
assert (
route._validate_value("ditto_emotion_model", "doubao-seed-2-1-pro-250915") == "doubao-seed-2-1-pro-250915"
)
assert route._validate_value("ditto_emotion_model", "deepseek_v3") == "deepseek_v3"
def test_validate_bool(self):
route = self._route()
@@ -375,3 +387,42 @@ class TestAdminRouteSecretMasking:
assert "sk-new-key" not in str(result)
# 落库值必须是明文(服务要用),只有响应脱敏
assert svc.set_config.call_args[0][1] == "sk-new-key"
class TestAdminRouteModelOptions:
"""model_options 从 DB 的 ditto_emotion_model_options(JSON 数组)读取."""
def _route(self):
from apps.api.app.api.routes.admin import ditto_emotion as route
return route
def _mock_service(self, values):
svc = MagicMock()
svc.get_config = lambda key, default=None: values.get(key, default)
return svc
def _build(self, monkeypatch, values):
route = self._route()
monkeypatch.setattr(route, "get_system_config_service", lambda: self._mock_service(values))
return route._build_config_item("ditto_emotion_model")
def test_options_from_db_json(self, monkeypatch):
item = self._build(monkeypatch, {"ditto_emotion_model_options": '["ep-aaa", "ep-bbb"]'})
assert item["model_options"] == ["ep-aaa", "ep-bbb"]
def test_options_empty_when_unconfigured(self, monkeypatch):
item = self._build(monkeypatch, {})
assert item["model_options"] == []
def test_options_invalid_json(self, monkeypatch):
item = self._build(monkeypatch, {"ditto_emotion_model_options": "{not-json"})
assert item["model_options"] == []
def test_options_non_array_json(self, monkeypatch):
item = self._build(monkeypatch, {"ditto_emotion_model_options": '{"a": 1}'})
assert item["model_options"] == []
def test_options_elements_stringified(self, monkeypatch):
item = self._build(monkeypatch, {"ditto_emotion_model_options": '["ep-aaa", 123, null, true]'})
assert item["model_options"] == ["ep-aaa", "123", "True"]