diff --git a/apps/worker/worker_app/tasks/viral_video.py b/apps/worker/worker_app/tasks/viral_video.py index 3298225ed..625fa8952 100644 --- a/apps/worker/worker_app/tasks/viral_video.py +++ b/apps/worker/worker_app/tasks/viral_video.py @@ -764,7 +764,8 @@ def _fallback_script(job: ViralVideoJob) -> dict: # 口播超长时截断(字数上限统一从 constants 取) _max_v = get_duration_spec(dur)["max_chars"] if len(voiceover) > _max_v: - voiceover = voiceover[:_max_v].rstrip(",、。, ") + "。" + # ruff B005:多字符 rstrip 有误导性,用正则等价实现(按字符集去除末尾标点/空格) + voiceover = re.sub(r"[,、。, ]+$", "", voiceover[:_max_v]) + "。" base["shots"] = [ { "time_range": f"0-{dur}秒", diff --git a/packages/application/ditto_emotion_service.py b/packages/application/ditto_emotion_service.py index 05b999d81..edf0eef9e 100644 --- a/packages/application/ditto_emotion_service.py +++ b/packages/application/ditto_emotion_service.py @@ -1,8 +1,8 @@ """Ditto LLM 情绪分析服务 — #2076 后续:根据文案生成 emo_timeline. 职责: -1. 正则按 。!?; 初步分句 -2. 调 DoubaoClient.chat_completion 分析每句表情(emo: 0-7, intensity: 0-1) +1. 分句:先按 。!?; 句末标点切分;整段无句末标点且超长时按逗号等语义停顿二次切分 +2. 调 DoubaoClient.chat_completion 分析每句表情(emo: 0-7 八种全部开放, intensity: 0-1) 3. 结果 LRU 缓存(文案 hash → 情绪列表) 4. LLM 失败/超时/格式错 → 返回空列表(降级中性表情,不阻塞生成) 5. TTS 完成后按字数比例或 sentence_timings 对齐成秒级 timeline @@ -29,10 +29,17 @@ EMO_NEUTRAL = 4 EMO_SAD = 5 EMO_SURPRISE = 6 EMO_CONTEMPT = 7 -ALLOWED_EMOS = {EMO_HAPPY, EMO_NEUTRAL, EMO_SAD, EMO_SURPRISE} # 营销场景白名单 +ALLOWED_EMOS = set(range(8)) # 8 种表情全部开放(0-7);超出范围的非法值仍会被降级为中性 # ── 分句正则 ───────────────────────────────────────────────────── _SENT_SPLIT_RE = re.compile(r"(?<=[。!?;!?;])\s*") +# 二次切分:逗号/分号/顿号/冒号等语义停顿(lookbehind 保留标点在前句末尾) +_CLAUSE_SPLIT_RE = re.compile(r"(?<=[,,、::])") +# 整段无句末标点且超过该长度时才做逗号二次切分 +_SINGLE_SENTENCE_THRESHOLD = 30 +# 二次切分后过短片段(<5 字,如"家人们,")并入相邻句避免切太碎; +# 4 字以上语义完整的短句(如"心中有爱,")保留独立成段 +_MIN_CLAUSE_LEN = 5 # ── 默认 prompt 模板文件路径 ────────────────────────────────────── _DEFAULT_PROMPT_PATH = Path(__file__).parent / "prompts" / "ditto_emotion.txt" @@ -45,8 +52,8 @@ def _load_default_prompt() -> str: # 文件不存在时用极简兜底 return ( "分析文案每句话表情,输出JSON数组:" - '[{"text":"句子","emo":4,"intensity":0.2}],emo:3开心4中性5伤心6惊讶,' - "禁止0/1/2/7。\n【文案】\n{文案}" + '[{"text":"句子","emo":4,"intensity":0.2}],emo:0-7八种表情全部开放,' + "至少30%句子用非中性表情。\n【文案】\n{文案}" ) @@ -86,12 +93,46 @@ class EmotionTimelineEntry: # ── 分句 ───────────────────────────────────────────────────────── +def _split_by_clauses(sentence: str) -> list[str]: + """按逗号/分号等语义停顿二次切分;过短片段(<5 字)并入相邻句,避免切太碎. + + 每句尽量控制在 10-50 字左右(50 字为软上限,无更细停顿点时保持语义完整不硬切)。 + """ + raw = [c for c in _CLAUSE_SPLIT_RE.split(sentence) if c and c.strip()] + if len(raw) <= 1: + return [sentence] + merged: list[str] = [] + buf = "" + for clause in raw: + if not buf: + buf = clause + continue + if len(buf) < _MIN_CLAUSE_LEN: + buf += clause + continue + merged.append(buf) + buf = clause + if buf: + if merged and len(buf) < _MIN_CLAUSE_LEN: + merged[-1] += buf + else: + merged.append(buf) + return [m.strip() for m in merged if m.strip()] + + def split_sentences(text: str) -> list[str]: - """按中文句末标点切分,过滤空串.""" + """按中文句末标点切分,过滤空串. + + Bug1 修复:若按句末标点切分后只有 1 段且文本超过 30 字(文案全是逗号、 + 结尾无标点),再按逗号/分号等语义停顿做二次切分,避免整段只返回 1 个 + segment 导致 LLM 只能对整段打一个表情。 + """ if not text: return [] - parts = _SENT_SPLIT_RE.split(text.strip()) - return [p.strip() for p in parts if p and p.strip()] + parts = [p.strip() for p in _SENT_SPLIT_RE.split(text.strip()) if p and p.strip()] + if len(parts) <= 1 and parts and len(parts[0]) > _SINGLE_SENTENCE_THRESHOLD: + parts = _split_by_clauses(parts[0]) + return parts # ── 解析 LLM 返回的 JSON ───────────────────────────────────────── @@ -202,6 +243,25 @@ def align_timeline_by_timings( return entries +# ── LLM 结果合理性校验(提示词遵循度排查)───────────────────────── +# 感叹号或明显情绪关键词出现时,LLM 不应返回全中性/低强度 +_EMO_SIGNAL_RE = re.compile(r"[!!]|是不是|有没有|居然|竟然|只要|太坑|千万别|免费|赶紧|推荐|惊艳") + + +def _warn_if_suspicious_all_neutral(text: str, segments: list[EmotionSegment]) -> None: + """文案含明显情绪信号但 LLM 返回全部 emo=4 且 intensity<=0.1 时打 warning 日志.""" + if not segments: + return + all_flat = all(s.emo == EMO_NEUTRAL and s.intensity <= 0.1 for s in segments) + if all_flat and _EMO_SIGNAL_RE.search(text): + logger.warning( + "[ditto_emotion] 文案含情绪信号(感叹号/关键词)但 LLM 返回全中性/低强度," + "疑似提示词遵循度差: text=%.80s, segments=%d", + text, + len(segments), + ) + + # ── LLM 情绪分析服务 ───────────────────────────────────────────── class DittoEmotionService: """Ditto 情绪分析服务(带 LRU 缓存).""" @@ -275,6 +335,8 @@ class DittoEmotionService: segments = _parse_emotion_json(result) if not segments: logger.warning("[ditto_emotion] LLM 返回解析失败: %s", result[:200]) + else: + _warn_if_suspicious_all_neutral(text, segments) return segments def analyze(self, text: str) -> list[EmotionSegment]: diff --git a/packages/application/prompts/ditto_emotion.txt b/packages/application/prompts/ditto_emotion.txt index 55abf5b0d..d8fa14279 100644 --- a/packages/application/prompts/ditto_emotion.txt +++ b/packages/application/prompts/ditto_emotion.txt @@ -1,27 +1,42 @@ -你是一个数字人视频表情导演。给定一段口播文案,分析每句话应该用什么表情和强度,让数字人说话时表情自然有变化,不僵硬。 -【表情编号】 -0=愤怒(营销场景禁用) -1=厌恶(禁用) -2=害怕(禁用) -3=开心:介绍优点、优惠、好消息、号召行动时用 -4=中性:默认表情,陈述事实、平铺直叙时用 -5=伤心:仅在共情用户痛点时低强度使用(如"是不是经常遇到…") -6=惊讶:惊喜、意外、强调价值时用(如"居然""只要""竟然") -7=轻蔑(禁用) -【强度说明】 -0.1-0.2:几乎看不出变化,比中性多一点情绪色彩 -0.3-0.4:有明显但自然的情绪,正常说话的波动 -0.5-0.6:较强情绪,感叹句/重点强调 -0.7+:极强情绪,极少使用 -【规则】 -1. 按自然语义分句,以。!?;为主要分界,逗号不分 -2. 60-70%的句子应该用中性(4),不要每句都标情绪 -3. 情绪和内容匹配:卖点→开心(3),痛点共情→伤心(5)低强度,惊喜/划算→惊讶(6),陈述→中性(4) -4. 相邻句子情绪不要剧烈跳变 -5. 感叹号结尾强度0.4-0.6,句号结尾一般0.1-0.3 -6. 开头结尾句用中性(4)或低强度开心(3) -7. 禁止使用0/1/2/7 -【输出格式】严格JSON数组,不要输出其他内容 -[{"text":"句子原文","emo":3,"intensity":0.4}] +你是一个数字人短视频表情导演。为每句话分配一个最贴切的表情,让数字人表情丰富、有情绪感染力,像真人说话一样有起伏变化。 + +【8种表情】 +0=愤怒:怒斥、骂坑、强烈不满、控诉 +1=厌恶:嫌弃、吐槽劣质、鄙视套路 +2=害怕:紧张、警告、危险提示、悬念 +3=开心:夸赞、推荐、好消息、福利、号召 +4=中性:陈述事实、客观描述、数据、过渡衔接 +5=伤心:共情痛点、遗憾、难过、踩坑经历 +6=惊讶:惊喜、超预期、居然、竟然、只要、数字冲击 +7=轻蔑:嘲讽、冷笑、拆穿套路、不屑 + +【分句说明】 +- 输入文案已按句末标点预分句;若某句内部仍有明显语义停顿(逗号、分号),你应根据语义进一步拆分成更细的句子分别打分 +- 若输入没有句末标点,要自己根据语义在逗号处分句 + +【强度】0.1几乎无变化 0.3轻微自然 0.5明显情绪 0.7强烈 0.9爆发 +感叹号结尾0.4-0.7,句号0.2-0.4,问号0.3-0.6。 + +【示例1】 +文案:"家人们!你们是不是经常买东西踩坑?花了大价钱买回来的东西根本没法用!但是今天这款产品真的惊艳到我了!" +输出: +[{"text":"家人们!","emo":6,"intensity":0.5},{"text":"你们是不是经常买东西踩坑?","emo":5,"intensity":0.3},{"text":"花了大价钱买回来的东西根本没法用!","emo":0,"intensity":0.5},{"text":"但是今天这款产品真的惊艳到我了!","emo":3,"intensity":0.6}] + +【示例2】 +文案:"做公益不一定是一个人去做惊天动地的大事,而是一群人扎扎实实的干好身边的小事,心中有爱,眼里有光。" +输出: +[{"text":"做公益不一定是一个人去做惊天动地的大事,","emo":4,"intensity":0.2},{"text":"而是一群人扎扎实实的干好身边的小事,","emo":4,"intensity":0.2},{"text":"心中有爱,","emo":3,"intensity":0.4},{"text":"眼里有光。","emo":3,"intensity":0.5}] + +【关键要求】 +- 每句话都必须独立判断情绪,至少有30%的句子使用非中性(4)表情!感叹号结尾的句子intensity不得低于0.4! +- 严禁全部句子都返回中性(4)!严禁全部强度都是0.1! +- 情绪要有变化:痛点段伤心或愤怒→转折→卖点开心→福利惊讶→号召开心 +- 语义优先:根据每句话的实际语义选择表情,不要固定开头结尾情绪 +- 8种表情全部开放,该愤怒就愤怒(0),该厌恶就厌恶(1),该害怕就害怕(2),该轻蔑就轻蔑(7) +- 开头第一句就根据内容定情绪,不要强行用中性开场 + +【输出】严格JSON数组,只输出JSON,不要其他文字。 +[{"text":"原文","emo":3,"intensity":0.5}] + 【文案】 {文案} diff --git a/packages/config/base.py b/packages/config/base.py index 526202748..48aed687e 100755 --- a/packages/config/base.py +++ b/packages/config/base.py @@ -219,7 +219,7 @@ class SharedSettings(BaseSettings): validation_alias=AliasChoices("DITTO_EMOTION_MODEL", "ditto_emotion_model"), ) ditto_emotion_temperature: float = Field( - default=0.1, + default=0.8, validation_alias=AliasChoices("DITTO_EMOTION_TEMPERATURE", "ditto_emotion_temperature"), ) ditto_emotion_timeout: int = Field( diff --git a/tests/unit/test_ditto_emotion_service.py b/tests/unit/test_ditto_emotion_service.py index bae0d2e8b..985e4e844 100644 --- a/tests/unit/test_ditto_emotion_service.py +++ b/tests/unit/test_ditto_emotion_service.py @@ -13,6 +13,7 @@ from packages.application.ditto_emotion_service import ( DittoEmotionService, EmotionSegment, _parse_emotion_json, + _warn_if_suspicious_all_neutral, align_timeline_by_length, align_timeline_by_timings, split_sentences, @@ -36,6 +37,34 @@ class TestSplitSentences: sents = split_sentences("Hello! How are you? I'm fine.") assert len(sents) == 3 + # ── Bug1 修复:无句末标点长文案按逗号二次切分 ── + def test_no_ending_punct_long_text_split_by_comma(self): + """公益文案:全是逗号、结尾无标点,整段超过30字 → 逗号二次切分.""" + text = "做公益不一定是一个人去做惊天动地的大事,而是一群人扎扎实实的干好身边的小事,心中有爱,眼里有光" + sents = split_sentences(text) + assert len(sents) >= 3 + assert any("心中有爱" in s for s in sents) + assert any("眼里有光" in s for s in sents) + + def test_short_text_no_split(self): + """不超过30字的无句末标点文案不二次切分.""" + text = "大家好,欢迎收看" + assert split_sentences(text) == [text] + + def test_short_clauses_merged(self): + """过短片段(<10字)并入相邻句,避免切太碎.""" + text = "家人们,你们是不是经常买东西踩坑呢,花了大价钱买回来的东西根本没法用啊" + sents = split_sentences(text) + assert len(sents) == 2 + assert all(len(s) >= 10 for s in sents) + + def test_normal_punct_no_clause_split(self): + """有句末标点正常切分的文案不做逗号二次切分.""" + text = "大家好!今天给大家推荐一款超棒的产品,它真的很好用。不信你试试?" + sents = split_sentences(text) + assert len(sents) == 3 + assert "今天给大家推荐一款超棒的产品,它真的很好用。" in sents + # ── JSON 解析 ──────────────────────────────────────────────────── class TestParseEmotionJson: @@ -52,8 +81,17 @@ class TestParseEmotionJson: assert len(segs) == 1 assert segs[0].emo == 3 - def test_forbidden_emo_becomes_neutral(self): - raw = json.dumps([{"text": "怒", "emo": 0, "intensity": 0.8}]) + def test_all_eight_emos_allowed(self): + """8 种表情全部开放:0-7 原样保留,不再强制转中性.""" + for emo in range(8): + raw = json.dumps([{"text": "句", "emo": emo, "intensity": 0.5}]) + segs = _parse_emotion_json(raw) + assert len(segs) == 1 + assert segs[0].emo == emo + + def test_out_of_range_emo_becomes_neutral(self): + """超出 0-7 范围的非法 emo 值仍降级为中性.""" + raw = json.dumps([{"text": "句", "emo": 9, "intensity": 0.5}]) segs = _parse_emotion_json(raw) assert len(segs) == 1 assert segs[0].emo == EMO_NEUTRAL @@ -280,3 +318,35 @@ class TestSingleSentenceFallback: assert segs[0].intensity == 0.1 finally: m._cached_analyze.cache_clear() + + +# ── LLM 结果合理性校验(Bug2)──────────────────────────────────── +class TestWarnIfSuspiciousAllNeutral: + def test_warns_when_all_neutral_with_signals(self, caplog): + """文案含感叹号但全中性/低强度 → warning.""" + segs = [EmotionSegment(text="家人们!", emo=4, intensity=0.1)] + with caplog.at_level("WARNING", logger="packages.application.ditto_emotion_service"): + _warn_if_suspicious_all_neutral("家人们!你们是不是经常踩坑?", segs) + assert any("全中性" in r.message or "全中性" in r.getMessage() for r in caplog.records) + + def test_no_warn_when_has_emotion(self, caplog): + """有非中性表情 → 不 warning.""" + segs = [ + EmotionSegment(text="家人们!", emo=6, intensity=0.5), + EmotionSegment(text="你们是不是经常踩坑?", emo=5, intensity=0.3), + ] + with caplog.at_level("WARNING", logger="packages.application.ditto_emotion_service"): + _warn_if_suspicious_all_neutral("家人们!你们是不是经常踩坑?", segs) + assert not caplog.records + + def test_no_warn_when_no_signal(self, caplog): + """文案无情绪信号,即使全中性也不 warning.""" + segs = [EmotionSegment(text="今天天气不错。", emo=4, intensity=0.1)] + with caplog.at_level("WARNING", logger="packages.application.ditto_emotion_service"): + _warn_if_suspicious_all_neutral("今天天气不错。", segs) + assert not caplog.records + + def test_no_warn_when_empty(self, caplog): + with caplog.at_level("WARNING", logger="packages.application.ditto_emotion_service"): + _warn_if_suspicious_all_neutral("家人们!", []) + assert not caplog.records