Merge pull request 'fix(ditto): 情绪分析彻底修复——智能分句+8表情开放+prompt few-shot' (#2276) from fix/ditto-emotion-thorough-fix into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
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 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m56s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (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 / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 57s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m7s
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 / 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 Staging Worker Image (push) Successful in 1m46s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m38s
AI Code Review / AI Code Review (pull_request) Successful in 7m3s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 10m22s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m1s
CI/CD Pipeline / Integration Tests (push) Successful in 9m17s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 11m43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m52s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m55s
CI/CD Pipeline / Unit Tests (push) Successful in 18m45s
CI/CD Pipeline / Validate - Security (push) Successful in 34m7s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped

This commit was merged in pull request #2276.
This commit is contained in:
2026-10-12 17:13:59 +08:00
5 changed files with 185 additions and 37 deletions
+2 -1
View File
@@ -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}秒",
+70 -8
View File
@@ -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]:
+40 -25
View File
@@ -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}]
【文案】
{文案}
+1 -1
View File
@@ -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(
+72 -2
View File
@@ -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