Skip to content

Commit 4d7ae33

Browse files
committed
fix(study): address latest review feedback
1 parent 4b58501 commit 4d7ae33

6 files changed

Lines changed: 125 additions & 32 deletions

File tree

main_logic/core.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,7 @@ def __init__(self, sync_message_queue, lanlan_name, lanlan_prompt):
844844
self._takeover_input_dispatcher: Optional[
845845
Callable[..., Awaitable[bool]]
846846
] = None
847+
self._latest_voice_transcript_request_id = ""
847848
# 由前端控制的Agent相关开关
848849
self.agent_flags = {
849850
'agent_enabled': False,
@@ -1975,6 +1976,30 @@ async def _dispatch_voice_transcript_bridge(
19751976
) -> str:
19761977
"""Let plugin-side voice filters decide whether to cancel or prime context."""
19771978
session_snapshot = self.session
1979+
request_id_snapshot = str(request_id or "")
1980+
1981+
def _session_changed() -> bool:
1982+
if self.session is session_snapshot:
1983+
return False
1984+
logger.debug("[%s] voice bridge result ignored after session change", self.lanlan_name)
1985+
return True
1986+
1987+
def _request_stale() -> bool:
1988+
if not request_id_snapshot:
1989+
return False
1990+
latest_request_id = str(
1991+
getattr(self, "_latest_voice_transcript_request_id", "") or ""
1992+
)
1993+
if latest_request_id == request_id_snapshot:
1994+
return False
1995+
logger.debug(
1996+
"[%s] voice bridge result ignored after newer request latest=%s current=%s",
1997+
self.lanlan_name,
1998+
latest_request_id,
1999+
request_id_snapshot,
2000+
)
2001+
return True
2002+
19782003
metadata: dict[str, Any] = {
19792004
"session_type": type(session_snapshot).__name__ if session_snapshot else "",
19802005
"voice_source": True,
@@ -1983,6 +2008,9 @@ async def _dispatch_voice_transcript_bridge(
19832008
if request_id:
19842009
metadata["request_id"] = request_id
19852010

2011+
if _session_changed() or _request_stale():
2012+
return ""
2013+
19862014
try:
19872015
result = await publish_voice_transcript_request_reliably(
19882016
self.lanlan_name,
@@ -1997,13 +2025,7 @@ async def _dispatch_voice_transcript_bridge(
19972025
if not isinstance(result, dict) or not result:
19982026
return ""
19992027

2000-
def _session_changed() -> bool:
2001-
if self.session is session_snapshot:
2002-
return False
2003-
logger.debug("[%s] voice bridge result ignored after session change", self.lanlan_name)
2004-
return True
2005-
2006-
if _session_changed():
2028+
if _session_changed() or _request_stale():
20072029
return ""
20082030

20092031
action = str(result.get("action") or "").strip()
@@ -2013,10 +2035,10 @@ def _session_changed() -> bool:
20132035
logger.debug("[%s] voice bridge cancel skipped: session has no cancel_response", self.lanlan_name)
20142036
return ""
20152037
try:
2016-
if _session_changed():
2038+
if _session_changed() or _request_stale():
20172039
return ""
20182040
await cancel_response()
2019-
if _session_changed():
2041+
if _session_changed() or _request_stale():
20202042
return ""
20212043
logger.debug("[%s] voice bridge cancelled current response", self.lanlan_name)
20222044
except asyncio.CancelledError:
@@ -2039,9 +2061,11 @@ def _session_changed() -> bool:
20392061
return ""
20402062
skipped = bool(result.get("skipped", False))
20412063
try:
2042-
if _session_changed():
2064+
if _session_changed() or _request_stale():
20432065
return ""
20442066
await prime_context(context_text, skipped=skipped)
2067+
if _session_changed() or _request_stale():
2068+
return ""
20452069
logger.debug(
20462070
"[%s] voice bridge primed context len=%d skipped=%s",
20472071
self.lanlan_name,
@@ -2207,6 +2231,8 @@ async def handle_input_transcript(self, transcript: str, *, is_voice_source: boo
22072231
if is_voice_source and transcript_text
22082232
else ""
22092233
)
2234+
if realtime_voice_request_id:
2235+
self._latest_voice_transcript_request_id = realtime_voice_request_id
22102236

22112237
# 更新用户活动时间戳(用于主动搭话检测)
22122238
self.last_user_activity_time = time.time()

plugin/plugins/study_companion/models.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -239,14 +239,6 @@ def to_dict(self) -> dict[str, Any]:
239239
return asdict(self)
240240

241241

242-
def _clamp_int(value: object, minimum: int, maximum: int, default: int) -> int:
243-
try:
244-
number = int(value)
245-
except (TypeError, ValueError, OverflowError):
246-
number = default
247-
return max(minimum, min(maximum, number))
248-
249-
250242
@dataclass(slots=True)
251243
class AwarenessConfig:
252244
enabled: bool = False
@@ -261,18 +253,20 @@ class AwarenessConfig:
261253

262254
def __post_init__(self) -> None:
263255
self.enabled = bool(self.enabled)
264-
self.snapshot_interval_seconds = _clamp_int(
256+
self.snapshot_interval_seconds = _clamp_int_or_default(
265257
self.snapshot_interval_seconds, 1, 60, 5
266258
)
267-
self.context_window_minutes = _clamp_int(
259+
self.context_window_minutes = _clamp_int_or_default(
268260
self.context_window_minutes, 1, 60, 5
269261
)
270262
mode = str(self.classify_mode or "title_first").strip().lower()
271263
self.classify_mode = (
272264
mode if mode in {"title_first", "ocr_text", "both"} else "title_first"
273265
)
274-
self.image_max_bytes = _clamp_int(self.image_max_bytes, 10_240, 512_000, 65_536)
275-
self.push_to_llm_interval_seconds = _clamp_int(
266+
self.image_max_bytes = _clamp_int_or_default(
267+
self.image_max_bytes, 10_240, 512_000, 65_536
268+
)
269+
self.push_to_llm_interval_seconds = _clamp_int_or_default(
276270
self.push_to_llm_interval_seconds, 30, 300, 300
277271
)
278272
push_mode = str(self.push_to_llm_mode or "read").strip().lower()

plugin/plugins/study_companion/static/i18n.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,8 @@ const I18n = {
143143
el.setAttribute('placeholder', this.t(key, el.getAttribute('placeholder') || ''));
144144
}
145145
});
146-
root.querySelectorAll('[data-i18n-aria-label]').forEach((el) => {
147-
const key = el.getAttribute('data-i18n-aria-label');
146+
root.querySelectorAll('[data-i18n-aria], [data-i18n-aria-label]').forEach((el) => {
147+
const key = el.getAttribute('data-i18n-aria') || el.getAttribute('data-i18n-aria-label');
148148
if (key) {
149149
el.setAttribute('aria-label', this.t(key, el.getAttribute('aria-label') || ''));
150150
}

plugin/plugins/study_companion/static/index.html

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
</head>
1010
<body>
1111
<main class="study-shell">
12-
<section class="study-toolbar" data-i18n-aria-label="ui.label.study_controls" aria-label="Study companion controls">
12+
<section class="study-toolbar" data-i18n-aria="ui.label.study_controls" aria-label="Study companion controls">
1313
<div>
1414
<h1 data-i18n="ui.title">Study Companion</h1>
1515
<p id="statusLine" data-i18n="ui.status.starting">Starting...</p>
@@ -20,13 +20,13 @@ <h1 data-i18n="ui.title">Study Companion</h1>
2020
</div>
2121
</section>
2222

23-
<section class="mode-strip" data-i18n-aria-label="ui.label.mode">
23+
<section class="mode-strip" data-i18n-aria="ui.label.mode">
2424
<button id="modeCompanionBtn" type="button" data-mode="companion" data-i18n="status.mode.companion">Companion</button>
2525
<button id="modeInteractiveBtn" type="button" data-mode="interactive" data-i18n="status.mode.interactive">Interactive</button>
2626
<button id="modeTeachingBtn" type="button" data-mode="teaching" data-i18n="status.mode.teaching">Teaching</button>
2727
</section>
2828

29-
<section class="study-meta" data-i18n-aria-label="ui.label.study_state" aria-label="Study state">
29+
<section class="study-meta" data-i18n-aria="ui.label.study_state" aria-label="Study state">
3030
<div class="meta-item">
3131
<span data-i18n="ui.label.screen">Screen</span>
3232
<strong id="screenType">-</strong>
@@ -41,7 +41,7 @@ <h1 data-i18n="ui.title">Study Companion</h1>
4141
</div>
4242
</section>
4343

44-
<section class="memory-panel" aria-live="polite" data-i18n-aria-label="ui.memory.title" aria-label="Memory Deck">
44+
<section class="memory-panel" aria-live="polite" data-i18n-aria="ui.memory.title" aria-label="Memory Deck">
4545
<div class="section-heading">
4646
<h2 data-i18n="ui.memory.title">Memory Deck</h2>
4747
<span id="memoryDeckStatus">-</span>
@@ -57,15 +57,15 @@ <h2 data-i18n="ui.memory.title">Memory Deck</h2>
5757
<button id="memoryAddBtn" type="button" data-i18n="ui.button.save_card">Save Card</button>
5858
</div>
5959
<div id="memoryDueCard" class="memory-card">-</div>
60-
<div class="memory-review-actions" data-i18n-aria-label="ui.memory.ratings_aria">
60+
<div class="memory-review-actions" data-i18n-aria="ui.memory.ratings_aria">
6161
<button type="button" data-memory-rating="again" data-i18n="ui.button.rating.again">Again</button>
6262
<button type="button" data-memory-rating="hard" data-i18n="ui.button.rating.hard">Hard</button>
6363
<button type="button" data-memory-rating="good" data-i18n="ui.button.rating.good">Good</button>
6464
<button type="button" data-memory-rating="easy" data-i18n="ui.button.rating.easy">Easy</button>
6565
</div>
6666
</section>
6767

68-
<section class="study-workspace" data-i18n-aria-label="ui.label.study_workspace" aria-label="Study workspace">
68+
<section class="study-workspace" data-i18n-aria="ui.label.study_workspace" aria-label="Study workspace">
6969
<label class="input-label" for="studyInput" data-i18n="ui.label.text">Text</label>
7070
<textarea id="studyInput" spellcheck="true" placeholder="Paste a concept, problem statement, or OCR text here." data-i18n-placeholder="ui.placeholder.input"></textarea>
7171
<div class="action-row">
@@ -74,7 +74,7 @@ <h2 data-i18n="ui.memory.title">Memory Deck</h2>
7474
</div>
7575
</section>
7676

77-
<section class="question-panel" aria-live="polite" data-i18n-aria-label="ui.label.question_panel" aria-label="Question panel">
77+
<section class="question-panel" aria-live="polite" data-i18n-aria="ui.label.question_panel" aria-label="Question panel">
7878
<h2 data-i18n="ui.label.question">Question</h2>
7979
<pre id="questionText"></pre>
8080
<label class="input-label" for="answerInput" data-i18n="ui.label.answer">Answer</label>
@@ -85,7 +85,7 @@ <h2 data-i18n="ui.label.question">Question</h2>
8585
</div>
8686
</section>
8787

88-
<section class="reply-panel" aria-live="polite" data-i18n-aria-label="ui.label.reply_panel" aria-label="Reply panel">
88+
<section class="reply-panel" aria-live="polite" data-i18n-aria="ui.label.reply_panel" aria-label="Reply panel">
8989
<h2 data-i18n="ui.label.reply">Reply</h2>
9090
<pre id="replyText"></pre>
9191
</section>

plugin/tests/unit/plugins/test_study_companion_phase9_ux.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,26 @@ def test_phase9_static_math_assets_are_local_and_registered() -> None:
3737
assert "trust: false" in renderer
3838

3939

40+
def test_phase9_static_ui_uses_standard_aria_i18n_attribute() -> None:
41+
index = (PLUGIN_DIR / "static" / "index.html").read_text(encoding="utf-8")
42+
i18n = (PLUGIN_DIR / "static" / "i18n.js").read_text(encoding="utf-8")
43+
44+
assert "data-i18n-aria-label" not in index
45+
for key in (
46+
"ui.label.study_controls",
47+
"ui.label.mode",
48+
"ui.label.study_state",
49+
"ui.memory.title",
50+
"ui.memory.ratings_aria",
51+
"ui.label.study_workspace",
52+
"ui.label.question_panel",
53+
"ui.label.reply_panel",
54+
):
55+
assert f'data-i18n-aria="{key}"' in index
56+
assert "[data-i18n-aria]" in i18n
57+
assert "getAttribute('data-i18n-aria')" in i18n
58+
59+
4060
def test_phase9_hosted_study_panel_uses_span_based_katex_rendering() -> None:
4161
source = (PLUGIN_DIR / "surfaces" / "study_panel.tsx").read_text(encoding="utf-8")
4262

tests/unit/test_core_game_route_memory_contract.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ def _make_manager():
122122
mgr.tts_handler_task = None
123123
mgr._takeover_active = False
124124
mgr._takeover_input_dispatcher = None
125+
mgr._latest_voice_transcript_request_id = ""
125126
mgr.sent_responses = []
126127
mgr.user_activity = []
127128

@@ -381,6 +382,7 @@ async def fake_publish(lanlan_name, transcript, *, metadata):
381382
assert calls[0][2]["request_id"].startswith("realtime-stt-")
382383
assert calls[0][2]["source"] == "realtime_stt"
383384
assert "voice_session_id" not in calls[0][2]
385+
assert mgr._latest_voice_transcript_request_id == calls[0][2]["request_id"]
384386
assert session.cancelled == 1
385387
assert mgr._activity_tracker.voice_rms_count == 1
386388
assert mgr._activity_tracker.user_messages == []
@@ -389,6 +391,57 @@ async def fake_publish(lanlan_name, transcript, *, metadata):
389391
assert mgr.sync_message_queue.messages == []
390392

391393

394+
@pytest.mark.unit
395+
@pytest.mark.asyncio
396+
async def test_voice_transcript_bridge_skips_publish_for_stale_request(monkeypatch):
397+
async def fail_publish(*_args, **_kwargs):
398+
raise AssertionError("stale voice bridge request must not publish")
399+
400+
session = _FakeVoiceBridgeSession()
401+
mgr = _make_transcript_manager()
402+
mgr.session = session
403+
mgr._latest_voice_transcript_request_id = "req-new"
404+
monkeypatch.setattr(core_module, "publish_voice_transcript_request_reliably", fail_publish)
405+
406+
result = await core_module.LLMSessionManager._dispatch_voice_transcript_bridge(
407+
mgr,
408+
"screen echo",
409+
request_id="req-old",
410+
)
411+
412+
assert result == ""
413+
assert session.cancelled == 0
414+
assert session.prime_context_calls == []
415+
416+
417+
@pytest.mark.unit
418+
@pytest.mark.asyncio
419+
async def test_voice_transcript_bridge_ignores_result_that_becomes_stale(monkeypatch):
420+
session = _FakeVoiceBridgeSession()
421+
mgr = _make_transcript_manager()
422+
mgr.session = session
423+
mgr._latest_voice_transcript_request_id = "req-old"
424+
calls = []
425+
426+
async def fake_publish(lanlan_name, transcript, *, metadata):
427+
calls.append((lanlan_name, transcript, metadata))
428+
mgr._latest_voice_transcript_request_id = "req-new"
429+
return {"action": "cancel_response", "reason": "ocr_overlap"}
430+
431+
monkeypatch.setattr(core_module, "publish_voice_transcript_request_reliably", fake_publish)
432+
433+
result = await core_module.LLMSessionManager._dispatch_voice_transcript_bridge(
434+
mgr,
435+
"screen echo",
436+
request_id="req-old",
437+
)
438+
439+
assert result == ""
440+
assert calls[0][2]["request_id"] == "req-old"
441+
assert session.cancelled == 0
442+
assert session.prime_context_calls == []
443+
444+
392445
@pytest.mark.unit
393446
@pytest.mark.asyncio
394447
async def test_voice_transcript_bridge_cancel_without_session_handler_uses_ordinary_flow(

0 commit comments

Comments
 (0)