Skip to content

Commit d1ad918

Browse files
committed
refactor study voice bridge boundary
1 parent 552722c commit d1ad918

9 files changed

Lines changed: 213 additions & 313 deletions

File tree

app/agent_server.py

Lines changed: 78 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import mimetypes
1515
import json
16+
from collections.abc import Mapping
1617
mimetypes.add_type("application/javascript", ".js")
1718
import asyncio
1819
import uuid
@@ -1648,36 +1649,97 @@ async def _emit_agent_status_update(lanlan_name: Optional[str] = None) -> None:
16481649
pass
16491650

16501651

1652+
VOICE_TRANSCRIPT_CUSTOM_EVENT_TYPE = "voice_transcript"
1653+
VOICE_TRANSCRIPT_CUSTOM_EVENT_TIMEOUT_SECONDS = 1.0
1654+
1655+
1656+
def _voice_bridge_noop(reason: str, **extra: object) -> Dict[str, Any]:
1657+
return {
1658+
"action": "noop",
1659+
"reason": str(reason or "noop"),
1660+
**extra,
1661+
}
1662+
1663+
1664+
def _voice_transcript_request_has_text(event: Mapping[str, object] | None) -> bool:
1665+
if not isinstance(event, Mapping):
1666+
return False
1667+
return bool(str(event.get("transcript") or "").strip())
1668+
1669+
1670+
def _voice_transcript_custom_event_args(event: Mapping[str, object]) -> Dict[str, object]:
1671+
metadata = event.get("metadata")
1672+
return {
1673+
"transcript": str(event.get("transcript") or "").strip(),
1674+
"lanlan_name": str(event.get("lanlan_name") or ""),
1675+
"metadata": dict(metadata) if isinstance(metadata, Mapping) else {},
1676+
}
1677+
1678+
1679+
def _voice_bridge_action_from_dispatch_results(dispatch_results: object) -> Dict[str, Any]:
1680+
if not isinstance(dispatch_results, list) or not dispatch_results:
1681+
return _voice_bridge_noop("no_subscribers")
1682+
1683+
failure_count = 0
1684+
for item in dispatch_results:
1685+
if not isinstance(item, Mapping):
1686+
continue
1687+
if not bool(item.get("success")):
1688+
failure_count += 1
1689+
continue
1690+
result = item.get("result")
1691+
if not isinstance(result, Mapping):
1692+
continue
1693+
action = str(result.get("action") or "").strip()
1694+
if not action:
1695+
continue
1696+
payload: Dict[str, Any] = dict(result)
1697+
payload["action"] = action
1698+
plugin_id = str(item.get("plugin_id") or "").strip()
1699+
if plugin_id:
1700+
payload.setdefault("source_plugin", plugin_id)
1701+
source_event_id = str(item.get("event_id") or "").strip()
1702+
if source_event_id:
1703+
payload.setdefault("source_event_id", source_event_id)
1704+
return payload
1705+
1706+
return _voice_bridge_noop("no_handler_result", failures=failure_count)
1707+
1708+
1709+
async def _dispatch_voice_transcript_custom_event(
1710+
event: Mapping[str, object],
1711+
) -> Dict[str, Any]:
1712+
from plugin.server.application.plugins.dispatch_service import PluginDispatchService
1713+
1714+
dispatch_results = await PluginDispatchService().trigger_custom_event_subscribers(
1715+
event_type=VOICE_TRANSCRIPT_CUSTOM_EVENT_TYPE,
1716+
args=_voice_transcript_custom_event_args(event),
1717+
timeout=VOICE_TRANSCRIPT_CUSTOM_EVENT_TIMEOUT_SECONDS,
1718+
)
1719+
return _voice_bridge_action_from_dispatch_results(dispatch_results)
1720+
1721+
16511722
async def _handle_voice_transcript_request(event: Dict[str, Any]) -> None:
16521723
event_id = str((event or {}).get("event_id") or "")
16531724
lanlan_name = (event or {}).get("lanlan_name")
1654-
result: Dict[str, Any] = {"action": "noop", "reason": "unavailable"}
1725+
result: Dict[str, Any] = _voice_bridge_noop("unavailable")
16551726

16561727
try:
1657-
from plugin.server.application.plugins import voice_transcript_bridge
1658-
1659-
if not voice_transcript_bridge.voice_transcript_request_has_text(event):
1660-
result = voice_transcript_bridge.voice_transcript_noop("empty_transcript")
1728+
if not _voice_transcript_request_has_text(event):
1729+
result = _voice_bridge_noop("empty_transcript")
16611730
elif not Modules.analyzer_enabled:
1662-
result = voice_transcript_bridge.voice_transcript_noop("agent_disabled")
1731+
result = _voice_bridge_noop("agent_disabled")
16631732
elif not Modules.agent_flags.get("user_plugin_enabled", False):
1664-
result = voice_transcript_bridge.voice_transcript_noop(
1665-
"user_plugin_disabled"
1666-
)
1733+
result = _voice_bridge_noop("user_plugin_disabled")
16671734
else:
16681735
lifecycle_ready = bool(Modules.plugin_lifecycle_started)
16691736
if not lifecycle_ready:
16701737
lifecycle_ready = await _ensure_plugin_lifecycle_started()
16711738

16721739
if not lifecycle_ready:
1673-
result = voice_transcript_bridge.voice_transcript_noop(
1674-
"plugin_lifecycle_start_failed"
1675-
)
1740+
result = _voice_bridge_noop("plugin_lifecycle_start_failed")
16761741
else:
1677-
result = await voice_transcript_bridge.resolve_voice_transcript_request(
1678-
event,
1679-
timeout=voice_transcript_bridge.VOICE_TRANSCRIPT_DISPATCH_TIMEOUT_SECONDS,
1680-
)
1742+
result = await _dispatch_voice_transcript_custom_event(event)
16811743
except Exception as exc:
16821744
logger.debug(
16831745
"[VoiceBridge] plugin dispatch failed: event_id=%s lanlan=%s err=%s",

plugin/plugins/study_companion/__init__.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@
7878
from .ui_api import build_open_ui_payload
7979
from .ui_api import build_contribution_settings_payload, build_knowledge_map_payload
8080
from .ui_api import build_habit_dashboard_payload, build_pomodoro_status_payload
81+
from .voice_contracts import (
82+
VOICE_TRANSCRIPT_EVENT_ID,
83+
VOICE_TRANSCRIPT_EVENT_TYPE,
84+
voice_transcript_cancel_response,
85+
voice_transcript_noop,
86+
voice_transcript_prime_context,
87+
)
8188
from .voice_filter import VoiceFilter, _derive_subject, build_context_for_catgirl
8289

8390

@@ -744,8 +751,8 @@ def _screen_classification_context(self) -> dict[str, Any]:
744751
return dict(self._state.last_screen_classification)
745752

746753
@custom_event(
747-
event_type="voice_transcript",
748-
id="handle_transcript",
754+
event_type=VOICE_TRANSCRIPT_EVENT_TYPE,
755+
id=VOICE_TRANSCRIPT_EVENT_ID,
749756
name="Handle study voice transcript",
750757
description="Filter realtime study voice transcripts and return a voice-session action.",
751758
input_schema={
@@ -768,13 +775,13 @@ async def handle_voice_transcript(
768775
):
769776
text = str(transcript or "").strip()
770777
if not text:
771-
return Ok({"action": "noop", "reason": "empty_transcript"})
778+
return Ok(voice_transcript_noop("empty_transcript"))
772779
metadata_payload = metadata if isinstance(metadata, dict) else {}
773780
session_key = _voice_session_key(lanlan_name, metadata_payload)
774781

775782
async with self._lock:
776783
if self._state.status != STATUS_READY:
777-
return Ok({"action": "noop", "reason": "not_ready"})
784+
return Ok(voice_transcript_noop("not_ready"))
778785
state_snapshot_payload = self._state.to_dict()
779786

780787
# Voice filtering only needs a point-in-time view; avoid holding the
@@ -806,9 +813,9 @@ async def handle_voice_transcript(
806813
extra_names=[lanlan_name],
807814
)
808815
if filter_result is None:
809-
return Ok({"action": "noop", "reason": "not_matched"})
816+
return Ok(voice_transcript_noop("not_matched"))
810817
if not bool(filter_result.get("should_relay")):
811-
return Ok({"action": "cancel_response", "filter": dict(filter_result)})
818+
return Ok(voice_transcript_cancel_response(filter_payload=filter_result))
812819

813820
state_snapshot = SimpleNamespace(**state_snapshot_payload)
814821
context_text = build_context_for_catgirl(
@@ -819,20 +826,18 @@ async def handle_voice_transcript(
819826
).strip()
820827
if not context_text:
821828
return Ok(
822-
{
823-
"action": "noop",
824-
"reason": "empty_context",
825-
"filter": dict(filter_result),
826-
}
829+
voice_transcript_noop(
830+
"empty_context",
831+
filter=dict(filter_result),
832+
)
827833
)
828834
return Ok(
829-
{
830-
"action": "prime_context",
831-
"context": context_text,
832-
"skipped": False,
833-
"filter": dict(filter_result),
834-
"lanlan_name": str(lanlan_name or ""),
835-
}
835+
voice_transcript_prime_context(
836+
context_text,
837+
skipped=False,
838+
filter_payload=filter_result,
839+
lanlan_name=str(lanlan_name or ""),
840+
)
836841
)
837842

838843
async def _update_screen_classification(

plugin/server/application/plugins/event_contracts.py renamed to plugin/plugins/study_companion/voice_contracts.py

Lines changed: 66 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66

77
VOICE_TRANSCRIPT_EVENT_TYPE = "voice_transcript"
8+
VOICE_TRANSCRIPT_EVENT_ID = "handle_transcript"
89

910
VOICE_TRANSCRIPT_ACTION_NOOP = "noop"
1011
VOICE_TRANSCRIPT_ACTION_CANCEL_RESPONSE = "cancel_response"
@@ -21,6 +22,49 @@
2122
}
2223

2324

25+
def voice_transcript_noop(reason: str, **extra: object) -> dict[str, object]:
26+
payload = dict(extra)
27+
payload.update(
28+
{
29+
"action": VOICE_TRANSCRIPT_ACTION_NOOP,
30+
"reason": str(reason or "noop"),
31+
}
32+
)
33+
return payload
34+
35+
36+
def voice_transcript_cancel_response(
37+
*,
38+
filter_payload: Mapping[str, object] | None = None,
39+
**extra: object,
40+
) -> dict[str, object]:
41+
payload: dict[str, object] = dict(extra)
42+
payload["action"] = VOICE_TRANSCRIPT_ACTION_CANCEL_RESPONSE
43+
if filter_payload is not None:
44+
payload["filter"] = dict(filter_payload)
45+
return payload
46+
47+
48+
def voice_transcript_prime_context(
49+
context: str,
50+
*,
51+
skipped: bool = False,
52+
filter_payload: Mapping[str, object] | None = None,
53+
**extra: object,
54+
) -> dict[str, object]:
55+
payload: dict[str, object] = dict(extra)
56+
payload.update(
57+
{
58+
"action": VOICE_TRANSCRIPT_ACTION_PRIME_CONTEXT,
59+
"context": str(context or "").strip(),
60+
"skipped": bool(skipped),
61+
}
62+
)
63+
if filter_payload is not None:
64+
payload["filter"] = dict(filter_payload)
65+
return payload
66+
67+
2468
def _coerce_priority(value: object) -> float:
2569
if isinstance(value, bool):
2670
return 0.0
@@ -80,12 +124,11 @@ def _normalize_voice_transcript_candidate(
80124

81125
def arbitrate_voice_transcript_results(dispatch_results: object) -> dict[str, Any]:
82126
if not isinstance(dispatch_results, list) or not dispatch_results:
83-
return {
84-
"action": VOICE_TRANSCRIPT_ACTION_NOOP,
85-
"reason": "no_subscribers",
86-
"priority": 0.0,
87-
"skipped": False,
88-
}
127+
return voice_transcript_noop(
128+
"no_subscribers",
129+
priority=0.0,
130+
skipped=False,
131+
)
89132

90133
selected: tuple[int, float, int, dict[str, Any]] | None = None
91134
noop_count = 0
@@ -112,42 +155,29 @@ def arbitrate_voice_transcript_results(dispatch_results: object) -> dict[str, An
112155
if selected is not None:
113156
return selected[3]
114157
if noop_count:
115-
return {
116-
"action": VOICE_TRANSCRIPT_ACTION_NOOP,
117-
"reason": "all_noop",
118-
"priority": 0.0,
119-
"skipped": False,
120-
"handlers": noop_count,
121-
"failures": failure_count,
122-
}
123-
return {
124-
"action": VOICE_TRANSCRIPT_ACTION_NOOP,
125-
"reason": "no_handler_result",
126-
"priority": 0.0,
127-
"skipped": False,
128-
"failures": failure_count,
129-
}
130-
131-
132-
def arbitrate_custom_event_result(
133-
*,
134-
event_type: str,
135-
dispatch_results: object,
136-
) -> dict[str, Any]:
137-
if event_type == VOICE_TRANSCRIPT_EVENT_TYPE:
138-
return arbitrate_voice_transcript_results(dispatch_results)
139-
return {
140-
"action": "noop",
141-
"reason": "no_event_contract",
142-
"event_type": event_type,
143-
}
158+
return voice_transcript_noop(
159+
"all_noop",
160+
priority=0.0,
161+
skipped=False,
162+
handlers=noop_count,
163+
failures=failure_count,
164+
)
165+
return voice_transcript_noop(
166+
"no_handler_result",
167+
priority=0.0,
168+
skipped=False,
169+
failures=failure_count,
170+
)
144171

145172

146173
__all__ = [
147174
"VOICE_TRANSCRIPT_ACTION_CANCEL_RESPONSE",
148175
"VOICE_TRANSCRIPT_ACTION_NOOP",
149176
"VOICE_TRANSCRIPT_ACTION_PRIME_CONTEXT",
177+
"VOICE_TRANSCRIPT_EVENT_ID",
150178
"VOICE_TRANSCRIPT_EVENT_TYPE",
151-
"arbitrate_custom_event_result",
152179
"arbitrate_voice_transcript_results",
180+
"voice_transcript_cancel_response",
181+
"voice_transcript_noop",
182+
"voice_transcript_prime_context",
153183
]

plugin/server/application/plugins/dispatch_service.py

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@
88

99
from plugin.core.state import state
1010
from plugin.logging_config import get_logger
11-
from plugin.server.application.plugins.event_contracts import (
12-
arbitrate_custom_event_result,
13-
)
1411
from plugin.server.domain import RUNTIME_ERRORS
1512
from plugin.server.domain.errors import ServerDomainError
1613

@@ -295,22 +292,3 @@ async def _dispatch_handler(plugin_id: str, handler_event_id: str) -> dict[str,
295292
*(_dispatch_handler(plugin_id, event_id) for plugin_id, event_id in handlers)
296293
)
297294
)
298-
299-
async def trigger_arbitrated_custom_event(
300-
self,
301-
*,
302-
event_type: str,
303-
event_id: str = "",
304-
args: object,
305-
timeout: float,
306-
) -> dict[str, object]:
307-
dispatch_results = await self.trigger_custom_event_subscribers(
308-
event_type=event_type,
309-
event_id=event_id,
310-
args=args,
311-
timeout=timeout,
312-
)
313-
return arbitrate_custom_event_result(
314-
event_type=event_type,
315-
dispatch_results=dispatch_results,
316-
)

0 commit comments

Comments
 (0)