fix: keep inline Face components in the same bubble during segmented reply - #10049
fix: keep inline Face components in the same bubble during segmented reply#10049Shxiao101 wants to merge 5 commits into
Conversation
he-yufeng
left a comment
There was a problem hiding this comment.
The grouping mechanism works for the reported shape, but including ComponentType.Plain in INLINE_SEGMENT_TYPES over-merges: any run of adjacent text components now collapses into a single bubble, which silently disables segmented reply for plain-text chains.
Concrete counterexample: the segmentation-words feature from #3959 splits LLM output at configured markers into adjacent Plain components precisely so segmented reply can deliver them as separate bubbles with intervals. With this change, ["第一段。", "第二段。"] (two adjacent Plain comps) is one inline_group and goes out as a single message, so a feature that exists to split text into separate bubbles stops splitting. The same applies to any chain where the model or an upstream stage produced more than one text component without an emoji between them.
A narrower grouping keeps the reported fix without that regression: Face attaches to the adjacent bubble (the preceding one, and to the following one only when the chain starts with a Face, so a leading emoji never flies solo), while Plain always starts a new bubble. That keeps ["好的", Face(277), ",我知道了!"] in one bubble, leaves ["第一段。", "第二段。"] as two, and preserves the segmentation-words behavior. A regression test for the consecutive-Plain case would pin it either way; right now the tests only cover chains broken up by a Face or Record, which is why the over-merge passes.
The interval change (word count over the bubble's joined text) looks right, and Record can no longer absorb a following Face since the group breaks before non-inline comps.
EterUltimate
left a comment
There was a problem hiding this comment.
Found the root cause of the macOS Run pytest suite failure (job 103455275079): the new test test_log_interval_for_a_bubble_uses_total_plain_word_count is statistically flaky, the implementation itself looks correct.
Root cause: RespondStage._word_cnt (astrbot/core/pipeline/respond/stage.py on master) uses the English branch for all-ASCII text: word_count = len(text.split()). The grouped bubble text is "hello" + "world" = "helloworld", so _word_cnt returns 1, not the 2 the test assumes. The interval is therefore random.uniform(log10(2), log10(2) + 0.5) = [0.301, 0.801], while the test asserts >= log10(3) = 0.477 — any sample below 0.477 fails (~35% of runs).
Evidence:
- CI:
Run pytest suite (macos-latest)job 103455275079 failed withassert 0.47712125471966244 <= 0.30788529321914043(0.3079 is inside the actual [0.301, 0.801] range). - Local repro on Windows: 9 failures / 20 runs of this single test.
Minimal fix: add a trailing space so the grouped text is "hello world" (2 English words); the assertions then hold deterministically — see the suggestion below. Alternatively use CJK text (e.g. "你好"/"世界"), which takes the char-count branch and yields log10(5) bounds.
|
|
||
| # "hello" + "world" -> 2 words -> log10(3) ~= 0.477 | ||
| bubble = await stage._calc_comp_interval( | ||
| [Comp.Plain(text="hello"), Comp.Face(id=277), Comp.Plain(text="world")], |
There was a problem hiding this comment.
The grouped text is "hello" + "world" = "helloworld", but _word_cnt counts unspaced ASCII text as 1 word (len(text.split())), so the interval is drawn from [log10(2), log10(2)+0.5] = [0.301, 0.801] while the test asserts >= log10(3) = 0.477 — ~35% of runs fail (CI evidence: macOS job 103455275079, assert 0.47712125471966244 <= 0.30788529321914043; local repro 9/20 on Windows). Adding a trailing space makes it 2 words and the bounds deterministic:
| [Comp.Plain(text="hello"), Comp.Face(id=277), Comp.Plain(text="world")], | |
| [Comp.Plain(text="hello "), Comp.Face(id=277), Comp.Plain(text="world")], |
There was a problem hiding this comment.
Good diagnosis, matches what we found. Rather than adjusting the test, the implementation now counts words per Plain component and sums them over the bubble (2da0ca9), so hello + world = 2 words and the asserted bounds are exact — no trailing-space workaround needed.
|
Thanks for the catch — agreed that merging Plain runs silently disabled segmentation-words. Reworked in 2da0ca9:
One point I'd like to confirm: the comment mentions both " |
Sourcery withdrew this approval because the latest commits introduced blocking findings.
With
platform_settings.segmented_reply.enable: true, the respond stage sends every component of the message chain as its own message. An inline QQ Face in the middle of text (e.g.好的[Face:277],我知道了!) is therefore split into three separate bubbles:好的/[Face:277]/,我知道了!.Fixes #10047
Modifications / 改动点
astrbot/core/pipeline/respond/stage.py:_group_segment_chain()now builds the segmented-reply bubbles:Plainand non-inline components start a new bubble, and aFaceattaches to the preceding text bubble and glues thePlainfollowing it into the same bubble, so an inline emoji never becomes (or creates) a bubble of its own. AdjacentPlaincomponents without an emoji between them (segmentation-words output, feat: segment reply supports segmentation words #3959) keep their deliberate split._calc_comp_interval()accepts a bubble (a list of components) and computes the log-method interval from the Plain word counts, counted per component and summed. Single-component behavior is unchanged.tests/unit/test_respond_stage_segmented_reply.py: regression tests for the glued sentence, leading Face, consecutive Plain split, Record staying alone, Face after media, glue stopping at media, and the interval calculation.This is NOT a breaking change. / 这不是一个破坏性变更。
Screenshots or Test Results / 运行截图或测试结果
uv run pytest tests/unit— 993 passed (10 deselected locally for an unrelated environment issue that also fails on clean master)./scripts/pr_test_env.sh --profile neo(repo-recommended local check set):ruff format --check .clean (504 files),ruff check .passed, Neo critical tests 13 passed,main.pystartup smoke test onhttp://localhost:6185passedChecklist / 检查清单
😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
/ 我的更改经过了良好的测试,并已在上方提供了"验证步骤"和"运行截图"。
🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in
requirements.txtandpyproject.toml./ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到
requirements.txt和pyproject.toml文件相应位置。😮 My changes do not introduce malicious code.
/ 我的更改没有引入恶意代码。
Summary by Sourcery
Keep inline Faces with their surrounding text during segmented replies.
Bug Fixes:
Enhancements:
Tests: