feat(voice): 通用语音转写插件桥接框架#1607
Conversation
Add a plugin-agnostic voice transcript bridge that allows any plugin to subscribe to voice_transcript custom events and arbitrate between noop, cancel_response, and prime_context actions. Infrastructure changes: - event_contracts.py: voice transcript action constants, arbitration logic - dispatch_service.py: concurrent multi-plugin dispatch with per-plugin timeout isolation and args separation - voice_transcript_bridge.py: bridge constants, normalization, resolve entry - agent_event_bus.py: waiter table, reliable publish/notify with retry - core.py: _dispatch_voice_transcript_bridge with session snapshot guards - agent_server.py: _handle_voice_transcript_request background task - main_server.py: voice_bridge_result event routing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (10)
🚧 Files skipped from review as they are similar to previous changes (6)
Walkthrough本次PR实现语音转录插件桥接:在事件总线建立可靠请求/等待、新增插件订阅式分发与仲裁契约、将桥接集成到核心转录处理并由agent_server/main_server完成事件回填喵。 Changes语音转录事件桥接与插件仲裁
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 30c266394d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| app.include_router(game_router) | ||
| app.include_router(card_assist_router) | ||
| app.include_router(capture_router) |
There was a problem hiding this comment.
Re-include the card-assist router
In this router registration list, card_assist_router is no longer included, so the existing frontend calls to /api/card-assist/clarify, /generate, /refine, and /chat in static/js/character_card_manager.js will hit 404 in the main app even though main_routers/card_assist_router.py still defines those endpoints. This removes the character-card assistant flow for users opening the companion UI.
Useful? React with 👍 / 👎.
| ok, reasons = cm.is_agent_api_ready() | ||
| # 字段名保留 is_free_version(前端/下游 gate 消费者沿用),值取 agent 维度的 | ||
| # is_agent_free():判 agent 是否走内置免费模型,而非 core/assist 的版本免费。 | ||
| return {"ready": ok, "reasons": reasons, "is_free_version": cm.is_agent_free()} | ||
| return {"ready": ok, "reasons": reasons, "is_free_version": cm.is_free_version()} |
There was a problem hiding this comment.
Restore agent-scoped free flag
When the voice/core provider is free but the Agent model is a paid/custom model, this now reports is_free_version as true because is_free_version() is tied to the core/voice setting, while the surrounding agent gate consumers use this field for Agent-model quota/prompt behavior. ConfigManager.is_agent_free() is the agent-model truth source, so this regression can incorrectly show/free-gate the Agent path whenever core is free and agent is not.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (6)
tests/unit/test_agent_server_voice_bridge.py (1)
10-174: ⚡ Quick win漏了两条分支没测喵~
_handle_voice_transcript_request一共有四个 noop 分支,但这里只覆盖了agent_disabled和plugin_lifecycle_start_failed两个喵。empty_transcript(转写为空)和user_plugin_disabled(agent_flags["user_plugin_enabled"]为 False)这两条路径还没人守着,哪天有人改了判断顺序,笨蛋你都察觉不到的说~要不要本喵帮你补上这两条用例喵?🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unit/test_agent_server_voice_bridge.py` around lines 10 - 174, Add two missing unit tests for _handle_voice_transcript_request: one where transcript is empty (expect emitted payload result {"action":"noop","reason":"empty_transcript"}, ensure resolve_voice_transcript_request not called) and one where Modules.agent_flags["user_plugin_enabled"] is False (expect emitted payload result {"action":"noop","reason":"user_plugin_disabled"}, ensure resolve not called); follow existing test patterns (monkeypatch srv.Modules.analyzer_enabled/plugin_lifecycle_started, monkeypatch srv._emit_main_event to capture emitted, monkeypatch voice_transcript_bridge.resolve_voice_transcript_request to track calls) and assert emitted["payload"]["event_id"] matches input event_id and resolve was not invoked.plugin/tests/unit/server/test_plugin_dispatch_service.py (1)
178-219: ⚡ Quick win给未就绪插件补一条“不会触发 host 调用”的断言。
这里现在只校验了
PLUGIN_NOT_READY,但没有锁住“健康检查失败后必须短路、不再调用trigger_custom_event”这个契约喵。plugin/server/application/plugins/dispatch_service.py:167-183明确是在health_check失败后直接抛错;如果后续回归成对死插件仍发请求,这个用例还是会继续通过喵。可以直接补上的断言喵
assert results[0]["plugin_id"] == "alpha" assert results[0]["event_id"] == "handle_transcript" assert results[0]["success"] is False assert results[0]["code"] == "PLUGIN_NOT_READY" + assert stopped_host.calls == [] assert results[1] == { "plugin_id": "beta", "event_id": "handle_transcript", "success": True, "result": {"action": "cancel_response"},🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@plugin/tests/unit/server/test_plugin_dispatch_service.py` around lines 178 - 219, Add an assertion to the test_trigger_custom_event_subscribers_keeps_per_plugin_errors that verifies the not-ready plugin's host was not invoked after health_check failed: after creating stopped_host (the _Host instance) and calling PluginDispatchService().trigger_custom_event_subscribers, assert that stopped_host.calls is empty (e.g. assert not stopped_host.calls or assert stopped_host.calls == []) to ensure trigger_custom_event was short-circuited for that plugin per the behavior in PluginDispatchService.trigger_custom_event_subscribers.plugin/tests/unit/server/test_plugin_voice_transcript_bridge.py (1)
23-29: ⚡ Quick win空转录用例最好顺手锁一下“不会分发”这个副作用喵。
现在只校验了返回值;如果以后有人重构成“先调插件再返回
noop”,这个测试依然会绿喵。考虑注入_DispatchService(),再断言calls == [],这样才能把voice_transcript_bridge.py:34-43的短路契约一起守住喵。一个最小改法喵
`@pytest.mark.asyncio` async def test_resolve_voice_transcript_request_returns_noop_for_empty_text() -> None: + dispatch_service = _DispatchService() result = await voice_transcript_bridge.resolve_voice_transcript_request( - {"transcript": " "} + {"transcript": " "}, + dispatch_service=dispatch_service, # type: ignore[arg-type] ) assert result == {"action": "noop", "reason": "empty_transcript"} + assert dispatch_service.calls == []🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@plugin/tests/unit/server/test_plugin_voice_transcript_bridge.py` around lines 23 - 29, The test only asserts the noop return but not that no dispatch happened; modify test_resolve_voice_transcript_request_returns_noop_for_empty_text to inject or mock the _DispatchService (the dispatcher used by voice_transcript_bridge.resolve_voice_transcript_request) and assert its call list is empty after calling resolve_voice_transcript_request with {"transcript": " "}, so you verify both the return value and that the dispatcher's calls == [] (preserving the short-circuit contract in resolve_voice_transcript_request).plugin/server/application/plugins/dispatch_service.py (1)
290-294: 💤 Low value变量名遮蔽了外层参数喵~
第 292 行的
for plugin_id, event_id in handlers中的event_id遮蔽了外层参数event_id(第 206 行)。虽然在这个上下文中不会造成 bug,因为外层的event_id在这之后没有被使用,但可能会让人困惑喵~ 考虑换个变量名比如handler_event_id会更清晰喵!♻️ 建议的修改喵
return list( await asyncio.gather( - *(_dispatch_handler(plugin_id, event_id) for plugin_id, event_id in handlers) + *(_dispatch_handler(plugin_id, handler_event_id) for plugin_id, handler_event_id in handlers) ) )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@plugin/server/application/plugins/dispatch_service.py` around lines 290 - 294, The generator loop in the return statement uses "for plugin_id, event_id in handlers" which shadows the outer "event_id" parameter (see _dispatch_handler and handlers usage); rename the inner loop variable to something like "handler_event_id" and update the call to _dispatch_handler(plugin_id, handler_event_id) to avoid shadowing and improve clarity.plugin/server/application/plugins/event_contracts.py (2)
106-107: 💤 Low value
_coerce_priority被重复调用了喵~
candidate.get("priority")在_normalize_voice_transcript_candidate第 56 行已经被_coerce_priority处理过了,这里再调用一次是多余的喵。虽然不会造成错误,但可以稍微优化一下喵~♻️ 建议的修改喵
- priority = _coerce_priority(candidate.get("priority", 0)) + priority = candidate["priority"] # already coerced by _normalize_voice_transcript_candidate🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@plugin/server/application/plugins/event_contracts.py` around lines 106 - 107, 在 _normalize_voice_transcript_candidate 中已经对 candidate 的 priority 进行了 _coerce_priority 处理,所以在当前代码块不要再次调用 _coerce_priority(candidate.get("priority", 0)); 直接读取已归一化的 priority 字段(或使用在 _normalize_voice_transcript_candidate 中最终保存的变量)并用该值计算 rank(使用 VOICE_TRANSCRIPT_ACTION_RANK.get(action, 0) 保持不变),去掉重复的 _coerce_priority 调用以消除冗余。
139-143: 💤 Low value哼,常量定义了不用是要闹哪样喵?
第 140 行用了字符串字面量
"noop"而不是已经定义好的VOICE_TRANSCRIPT_ACTION_NOOP常量喵~ 虽然这里是针对未知事件类型的通用返回,但既然已经定义了常量,保持一致性会更好喵!♻️ 建议的修改喵
return { - "action": "noop", + "action": VOICE_TRANSCRIPT_ACTION_NOOP, "reason": "no_event_contract", "event_type": event_type, }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@plugin/server/application/plugins/event_contracts.py` around lines 139 - 143, 将返回字典中硬编码的字符串 "noop" 替换为已定义的常量 VOICE_TRANSCRIPT_ACTION_NOOP 以保持一致性;在插件的 event_contracts.py 中定位生成该返回值的函数/分支(当前返回 {"action": "noop", "reason": "no_event_contract", "event_type": event_type})并将 "noop" 替换为 VOICE_TRANSCRIPT_ACTION_NOOP,确保导入或在同一模块中引用该常量以避免未定义错误。
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/main_server.py`:
- Around line 611-615: 当前代码在处理 event_type == "voice_bridge_result" 时把非 dict 的
event.get("result") 默认为 {} 然后仍调用 notify_voice_bridge_result,导致畸形 payload
被当作成功完成;请修改该分支:先取 result = event.get("result") 并验证 isinstance(result, dict);若为
dict 则按原样调用 notify_voice_bridge_result(str(event.get("event_id") or ""),
result);若不是 dict 则不要调用 notify_voice_bridge_result,而应记录警告/错误日志(包含 event_id 与实际
payload),并让超时/重试或错误处理路径继续(不要吞掉或替换为 {}),以便事件总线能按预期处理失败或重试。
In `@main_logic/core.py`:
- Around line 1988-2011: The current logic in handle_input_transcript()
conflates session-change detection with consuming the transcript:
_session_changed() returning True causes an early return that drops the
transcript and prevents side effects (sync_message_queue, turn counts, metrics),
and the cancel_response branch returns "cancel_response" even when
cancel_response() wasn't executed or failed. Change the flow so that: 1) if
_session_changed() is True the function returns an empty action (allowing normal
transcript handling to continue) instead of swallowing the input; 2) in the
cancel_response branch call getattr(session_snapshot, "cancel_response", None)
and only return "cancel_response" when cancel_response is callable and executes
successfully; if the method is missing or raises, log the error and fall through
to the normal transcript handling path (do not return the action). Keep
references to _dispatch_voice_transcript_bridge(), session_snapshot vs
self.session, cancel_response, and sync_message_queue when updating the logic.
---
Nitpick comments:
In `@plugin/server/application/plugins/dispatch_service.py`:
- Around line 290-294: The generator loop in the return statement uses "for
plugin_id, event_id in handlers" which shadows the outer "event_id" parameter
(see _dispatch_handler and handlers usage); rename the inner loop variable to
something like "handler_event_id" and update the call to
_dispatch_handler(plugin_id, handler_event_id) to avoid shadowing and improve
clarity.
In `@plugin/server/application/plugins/event_contracts.py`:
- Around line 106-107: 在 _normalize_voice_transcript_candidate 中已经对 candidate 的
priority 进行了 _coerce_priority 处理,所以在当前代码块不要再次调用
_coerce_priority(candidate.get("priority", 0)); 直接读取已归一化的 priority 字段(或使用在
_normalize_voice_transcript_candidate 中最终保存的变量)并用该值计算 rank(使用
VOICE_TRANSCRIPT_ACTION_RANK.get(action, 0) 保持不变),去掉重复的 _coerce_priority
调用以消除冗余。
- Around line 139-143: 将返回字典中硬编码的字符串 "noop" 替换为已定义的常量
VOICE_TRANSCRIPT_ACTION_NOOP 以保持一致性;在插件的 event_contracts.py 中定位生成该返回值的函数/分支(当前返回
{"action": "noop", "reason": "no_event_contract", "event_type": event_type})并将
"noop" 替换为 VOICE_TRANSCRIPT_ACTION_NOOP,确保导入或在同一模块中引用该常量以避免未定义错误。
In `@plugin/tests/unit/server/test_plugin_dispatch_service.py`:
- Around line 178-219: Add an assertion to the
test_trigger_custom_event_subscribers_keeps_per_plugin_errors that verifies the
not-ready plugin's host was not invoked after health_check failed: after
creating stopped_host (the _Host instance) and calling
PluginDispatchService().trigger_custom_event_subscribers, assert that
stopped_host.calls is empty (e.g. assert not stopped_host.calls or assert
stopped_host.calls == []) to ensure trigger_custom_event was short-circuited for
that plugin per the behavior in
PluginDispatchService.trigger_custom_event_subscribers.
In `@plugin/tests/unit/server/test_plugin_voice_transcript_bridge.py`:
- Around line 23-29: The test only asserts the noop return but not that no
dispatch happened; modify
test_resolve_voice_transcript_request_returns_noop_for_empty_text to inject or
mock the _DispatchService (the dispatcher used by
voice_transcript_bridge.resolve_voice_transcript_request) and assert its call
list is empty after calling resolve_voice_transcript_request with {"transcript":
" "}, so you verify both the return value and that the dispatcher's calls ==
[] (preserving the short-circuit contract in resolve_voice_transcript_request).
In `@tests/unit/test_agent_server_voice_bridge.py`:
- Around line 10-174: Add two missing unit tests for
_handle_voice_transcript_request: one where transcript is empty (expect emitted
payload result {"action":"noop","reason":"empty_transcript"}, ensure
resolve_voice_transcript_request not called) and one where
Modules.agent_flags["user_plugin_enabled"] is False (expect emitted payload
result {"action":"noop","reason":"user_plugin_disabled"}, ensure resolve not
called); follow existing test patterns (monkeypatch
srv.Modules.analyzer_enabled/plugin_lifecycle_started, monkeypatch
srv._emit_main_event to capture emitted, monkeypatch
voice_transcript_bridge.resolve_voice_transcript_request to track calls) and
assert emitted["payload"]["event_id"] matches input event_id and resolve was not
invoked.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: d9b9198e-5b41-4477-9681-b525b6719b42
📒 Files selected for processing (13)
app/agent_server.pyapp/main_server.pymain_logic/agent_event_bus.pymain_logic/core.pyplugin/server/application/plugins/dispatch_service.pyplugin/server/application/plugins/event_contracts.pyplugin/server/application/plugins/voice_transcript_bridge.pyplugin/tests/unit/server/test_plugin_dispatch_service.pyplugin/tests/unit/server/test_plugin_event_contracts.pyplugin/tests/unit/server/test_plugin_voice_transcript_bridge.pytests/unit/test_agent_server_voice_bridge.pytests/unit/test_core_game_route_memory_contract.pytests/unit/test_voice_bridge_event_bus.py
做了什么新增插件无关的通用语音转写桥接框架,包含四层:
主程序侧(
为什么#1546 混入了主程序修改和伴学插件修改,reviewer 要求拆分。基础设施部分提取为这个 PR。 从架构上看,实时语音转写需要走插件层仲裁而非硬编码在 main |
摘要
新增插件无关的通用语音转写桥接框架,允许任意插件通过
@custom_event(event_type="voice_transcript")订阅实时语音转写事件,并参与多插件仲裁(noop / cancel_response / prime_context)。这是 #1546 拆分后的基础设施部分,不包含任何伴学插件代码。
架构层次
main_logic/agent_event_bus.pyplugin/server/.../dispatch_service.pyplugin/server/.../event_contracts.pyplugin/server/.../voice_transcript_bridge.pymain_logic/core.pyapp/agent_server.py+app/main_server.py变更文件
13 文件,+1673/-6 行。零
plugin/plugins/study_companion/文件。测试
53/53 通过 — 覆盖分发、仲裁、桥接、事件总线、agent 集成、core 合约。
Summary by CodeRabbit
发布说明