2a739dee17
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 1s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
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 Web 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 / PR Build API Image (pull_request) Successful in 51s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m7s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m9s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 4m41s
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m0s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 10m11s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 18m38s
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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 2s
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
问题7(generate-copy 提速,目标 30-40s):
- 新增 doubao_fast_model 配置(默认 doubao-1-5-pro-32k-250115),结构化输出任务(意图解析/编导脚本/合规审核)改用快模型,不再使用慢推理模型 doubao-seed-1-6
- call_llm 扩展支持 model/max_tokens/system_prompt 参数;chat_completion 同步支持 model 覆盖
- 编导脚本 temperature 0.8 + max_tokens 2500(从 4096 收紧);意图解析 max_tokens 800;审核 max_tokens 500
- _SCRIPT_GENERATION_PROMPT 精简冗余描述(前导说明和关键要求章节从 ~70 行压到 ~30 行),减少输入/输出 token
- 合规审核异步后置:阶段2 generate-copy 只做关键字黑名单快速检查(不调用 LLM),LLM 深度审核移到阶段3 confirm-copy TTS 之前执行,不再阻塞前端展示脚本
- 新增 _quick_compliance_blacklist_check 处理常见广告法绝对化用语
问题8(细粒度 phase + phase_message):
- ViralVideoJob 新增 phase_message 字段(中文提示文案,前端轮询直接展示)
- SQLAlchemy ViralVideoJobModel 新增 current_stage/phase_message 列(current_stage 原已有但未持久化更新)
- repo 层 _to_domain/save/update 同步处理新字段
- alembic 090 迁移:幂等 ADD COLUMN phase_message VARCHAR(500)
- 新增 _set_stage 辅助:统一设置 current_stage + phase_message + Redis 推送 + DB 持久化
- 所有 celery task(analyze/generate-copy/render/one-click/resume)在关键节点调用 _set_stage 持久化阶段信息
- 阶段文案:analyzing_images→正在分析商品特征 / parsing_intent→正在解析文案意图 / generating_script→正在编排分镜脚本 / reviewing→合规审核中 / tts→正在合成AI配音 / rendering→正在生成视频 / uploading→正在上传视频
- ViralVideoJobResponse schema + _to_response 增加 current_stage/phase_message,前端轮询 GET /{job_id} 直接拿到
配套:
- .env.example + render_env.sh SHARED_SECRETS 补 DOUBAO_FAST_MODEL
- tests _FakeSettings/_make_job 同步新增字段
- _run_render_pipeline 兜底补生成分支不再同步调用 _step_review(由出片前统一审核处理)
266 lines
8.5 KiB
Python
266 lines
8.5 KiB
Python
"""Additional unit tests to hit uncovered lines for diff-coverage >=60%."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from packages.shared.ai_client import DoubaoClient
|
|
|
|
|
|
class _FakeSettings:
|
|
doubao_api_key = "test-key"
|
|
doubao_model = "test-model"
|
|
doubao_fast_model = "test-fast-model"
|
|
doubao_base_url = "https://ark.cn-beijing.volces.com/api/v3"
|
|
doubao_timeout = 10
|
|
doubao_max_retries = 0
|
|
doubao_vision_model = "test-vision"
|
|
doubao_vision_lite_model = "test-vision-lite"
|
|
doubao_vision_use_lite = False
|
|
doubao_embedding_model = "test-embedding"
|
|
|
|
|
|
def _make_client(api_key: str = "test-key") -> DoubaoClient:
|
|
with patch("packages.shared.ai_client.get_shared_settings", return_value=_FakeSettings()):
|
|
c = DoubaoClient()
|
|
c.api_key = api_key
|
|
c.max_retries = 0
|
|
return c
|
|
|
|
|
|
class TestDoubaoClientEmbedText:
|
|
def test_no_api_key_returns_none(self):
|
|
c = _make_client(api_key="")
|
|
assert c.embed_text("hello") is None
|
|
|
|
def test_empty_text_returns_none(self):
|
|
c = _make_client()
|
|
assert c.embed_text("") is None
|
|
assert c.embed_text(" ") is None
|
|
|
|
def test_none_text_returns_none(self):
|
|
c = _make_client()
|
|
assert c.embed_text(None) is None
|
|
|
|
@patch("packages.shared.ai_client.httpx.post")
|
|
def test_successful_embedding(self, mock_post):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"data": [{"embedding": [0.1, 0.2, 0.3]}]}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
mock_post.return_value = mock_resp
|
|
c = _make_client()
|
|
result = c.embed_text("hello world")
|
|
assert result == [0.1, 0.2, 0.3]
|
|
mock_post.assert_called_once()
|
|
|
|
@patch("packages.shared.ai_client.httpx.post")
|
|
def test_malformed_response_returns_none(self, mock_post):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"data": []}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
mock_post.return_value = mock_resp
|
|
c = _make_client()
|
|
assert c.embed_text("hello") is None
|
|
|
|
@patch("packages.shared.ai_client.httpx.post", side_effect=Exception("network error"))
|
|
def test_network_error_returns_none(self, mock_post):
|
|
c = _make_client()
|
|
assert c.embed_text("hello") is None
|
|
|
|
def test_is_available_with_key(self):
|
|
c = _make_client(api_key="sk-xxx")
|
|
assert c.is_available is True
|
|
|
|
def test_is_available_without_key(self):
|
|
c = _make_client(api_key="")
|
|
assert c.is_available is False
|
|
|
|
|
|
# --- 2. _infer_expected_categories ---
|
|
_GEN_TASKS_PATH = Path(__file__).resolve().parents[2] / "apps/api/app/api/routes/generation_tasks.py"
|
|
|
|
|
|
def _load_infer_func():
|
|
src = _GEN_TASKS_PATH.read_text()
|
|
start = src.index("# #2035:文案关键词")
|
|
end = src.index("from packages.middleware")
|
|
code = src[start:end]
|
|
ns: dict = {}
|
|
exec(code, ns)
|
|
return ns["_infer_expected_categories"]
|
|
|
|
|
|
_infer_expected_categories = _load_infer_func()
|
|
|
|
|
|
class TestInferExpectedCategories:
|
|
def test_none_returns_none(self):
|
|
assert _infer_expected_categories(None) is None
|
|
assert _infer_expected_categories(set()) is None
|
|
|
|
def test_product_keyword_matches(self):
|
|
cats = _infer_expected_categories({"产品展示"})
|
|
assert cats is not None
|
|
assert "product" in cats
|
|
|
|
def test_scenic_keyword_matches(self):
|
|
cats = _infer_expected_categories({"户外风景"})
|
|
assert cats is not None
|
|
assert "scenic" in cats
|
|
|
|
def test_food_keyword_matches(self):
|
|
cats = _infer_expected_categories({"美食制作"})
|
|
assert cats is not None
|
|
assert "food" in cats
|
|
|
|
def test_no_match_returns_none(self):
|
|
assert _infer_expected_categories({"抽象概念xyz"}) is None
|
|
|
|
|
|
# --- 3. parse_vision_response edge cases ---
|
|
from packages.domain.atom_clip_tagger import parse_vision_response
|
|
|
|
|
|
class TestParseVisionResponseEdgeCases:
|
|
def test_person_count_type_error_defaults_zero(self):
|
|
text = json.dumps({
|
|
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
|
|
"person_count": "not-an-int", "text_content": "", "caption": "x",
|
|
})
|
|
r = parse_vision_response(text)
|
|
assert r["person_count"] == 0
|
|
|
|
def test_person_count_out_of_range_clamped(self):
|
|
text = json.dumps({
|
|
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
|
|
"person_count": 10, "text_content": "", "caption": "x",
|
|
})
|
|
r = parse_vision_response(text)
|
|
assert r["person_count"] == 3
|
|
|
|
def test_person_count_negative_clamped(self):
|
|
text = json.dumps({
|
|
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
|
|
"person_count": -5, "text_content": "", "caption": "x",
|
|
})
|
|
r = parse_vision_response(text)
|
|
assert r["person_count"] == 0
|
|
|
|
def test_text_content_non_string_defaults_empty(self):
|
|
text = '{"scene":[],"objects":[],"action":[],"shot":"","has_text":true,"person_count":0,"text_content":123,"caption":"x"}'
|
|
r = parse_vision_response(text)
|
|
assert r["text_content"] == ""
|
|
|
|
def test_caption_truncation_at_80(self):
|
|
long_caption = "描" * 100
|
|
text = json.dumps({
|
|
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
|
|
"person_count": 0, "text_content": "", "caption": long_caption,
|
|
})
|
|
r = parse_vision_response(text)
|
|
assert len(r["caption"]) == 80
|
|
|
|
|
|
# --- 4. smart_match normalize_tag ---
|
|
from packages.domain.smart_match import normalize_tag
|
|
|
|
|
|
class TestNormalizeTagEdge:
|
|
def test_none_returns_empty(self):
|
|
assert normalize_tag(None) == ""
|
|
|
|
def test_non_string_converted(self):
|
|
assert normalize_tag(123) == "123"
|
|
|
|
def test_strip_and_lower(self):
|
|
assert normalize_tag(" FOO Bar ") == "foo bar"
|
|
|
|
|
|
# --- 5. narrative_match non-dict clip_tags skip ---
|
|
from packages.domain.narrative_match import match_assets_by_script_tags
|
|
|
|
|
|
@dataclass
|
|
class _FA:
|
|
id: str
|
|
tags: list
|
|
|
|
|
|
class TestNarrativeMatchNonDictClipTags:
|
|
def test_non_dict_clip_tags_are_skipped(self):
|
|
a1 = _FA("a1", tags=[])
|
|
clip_map = {"a1": [None, "bad", {"scene": ["工厂"], "objects": [], "action": []}, 123]}
|
|
matched, unmatched = match_assets_by_script_tags(
|
|
[a1], script_tags=["工厂"], clip_ai_tags_by_asset=clip_map
|
|
)
|
|
assert [a.id for a in matched] == ["a1"]
|
|
|
|
|
|
# --- 6. update_caption_embedding ---
|
|
class _FakeSession:
|
|
def __init__(self, rows_found: int = 1):
|
|
self.rows_found = rows_found
|
|
self.commits = 0
|
|
self.updates = []
|
|
|
|
def query(self, model):
|
|
return _FQuery(self)
|
|
|
|
def commit(self):
|
|
self.commits += 1
|
|
|
|
|
|
class _FQuery:
|
|
def __init__(self, session):
|
|
self.session = session
|
|
|
|
def filter(self, *a, **kw):
|
|
return self
|
|
|
|
def update(self, upd):
|
|
self.session.updates.append(upd)
|
|
return self.session.rows_found
|
|
|
|
|
|
class TestUpdateCaptionEmbedding:
|
|
def _make_repo(self, session):
|
|
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import SQLAlchemyAssetAtomClipRepository
|
|
repo = SQLAlchemyAssetAtomClipRepository.__new__(SQLAlchemyAssetAtomClipRepository)
|
|
repo.session = session
|
|
return repo
|
|
|
|
def test_updates_both_caption_and_embedding(self):
|
|
s = _FakeSession(rows_found=1)
|
|
repo = self._make_repo(s)
|
|
ok = repo.update_caption_embedding("c1", "new caption", [0.1, 0.2])
|
|
assert ok is True
|
|
assert s.commits == 1
|
|
assert s.updates[0]["caption"] == "new caption"
|
|
assert s.updates[0]["embedding"] == [0.1, 0.2]
|
|
|
|
def test_only_caption_update(self):
|
|
s = _FakeSession(rows_found=1)
|
|
repo = self._make_repo(s)
|
|
ok = repo.update_caption_embedding("c1", "cap", None)
|
|
assert ok is True
|
|
assert "embedding" not in s.updates[0]
|
|
assert s.updates[0]["caption"] == "cap"
|
|
|
|
def test_no_update_when_both_none(self):
|
|
s = _FakeSession()
|
|
repo = self._make_repo(s)
|
|
ok = repo.update_caption_embedding("c1", None, None)
|
|
assert ok is False
|
|
assert s.commits == 0
|
|
assert s.updates == []
|
|
|
|
def test_returns_false_when_row_not_found(self):
|
|
s = _FakeSession(rows_found=0)
|
|
repo = self._make_repo(s)
|
|
ok = repo.update_caption_embedding("c1", "x", [0.1])
|
|
assert ok is False
|