"""Unified start_app_task / switch_app / mode helpers."""
|
|
from __future__ import annotations
|
|
import asyncio
|
import json
|
import difflib
|
import queue
|
import random
|
import re
|
import uuid
|
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
|
from config.logger import setup_logging
|
from plugins_func.register import Action, ActionResponse
|
|
from .assessment import (
|
evaluate_turn,
|
listen_window_ms,
|
pcm_snapshot_to_wav,
|
resolve_expected_text,
|
resolve_listen_ratio,
|
save_turn_wav,
|
silent_pcm_snapshot,
|
transcript_cache_dir,
|
)
|
from .playback import (
|
clamp_play_speed,
|
default_repeater_playlist,
|
default_shadowing_playlist,
|
expand_playlist_plays,
|
lesson_day_index,
|
load_last_lesson_params,
|
mark_lesson_played_today,
|
playlist_from_params,
|
remember_last_lesson_params,
|
resolve_play_speed,
|
speeds_for_repeats,
|
)
|
from .registry import (
|
default_app_id,
|
get_app,
|
get_registry,
|
get_task,
|
match_registry_voice,
|
voice_menu_text,
|
)
|
from .session import begin_session, current_session, end_session, parse_skill_event, schedule_close_parent_except
|
|
if TYPE_CHECKING:
|
from core.connection import ConnectionHandler
|
|
TAG = __name__
|
logger = setup_logging()
|
|
SOURCE_PRIORITY = {
|
"mqtt_immediate": 3,
|
"schedule": 2,
|
"voice": 1,
|
"default_chat": 0,
|
}
|
|
TRANSLATOR_PROMPT = (
|
"你是一台翻译机。只输出译文,不要解释、不要拼音课、不要追问、不要闲聊。"
|
"未指定语言时:中文译成英文,英文译成中文;其它语言默认译成中文。"
|
)
|
|
BILINGUAL_PROMPT = (
|
"你正在进行中英对照对话练习,不是翻译机,不要把用户的话原样译回去交差。"
|
"严格执行:用户说中文时你只用英文完整回答;用户说英文时你只用中文完整回答。"
|
"不要中英夹杂,不要同时给出两种语言,不要问要不要翻译。"
|
"用户说退出中英对话、回到聊天时调用 switch_app,app_id=chat。"
|
)
|
|
BILINGUAL_PAIR_PROMPTS = {
|
"zh-en": BILINGUAL_PROMPT,
|
"zh-fr": (
|
"你正在进行中法对照对话练习,不是翻译机,不要把用户的话原样译回去交差。"
|
"用户说中文时你用法语完整回答;用户说法语时你用中文完整回答。"
|
"不要两种语言同时输出。用户说退出对话、回到聊天时调用 switch_app,app_id=chat。"
|
),
|
"zh-ja": (
|
"你正在进行中日对照对话练习,不是翻译机,不要把用户的话原样译回去交差。"
|
"用户说中文时你用日语完整回答;用户说日语时你用中文完整回答。"
|
"不要两种语言同时输出。用户说退出对话、回到聊天时调用 switch_app,app_id=chat。"
|
),
|
}
|
|
TRANSLATOR_PAIR_PROMPTS = {
|
"zh-en": TRANSLATOR_PROMPT,
|
"zh-fr": "你是一台翻译机。只输出译文。中文译成法语,法语译成中文;其它语言默认译成中文。",
|
"zh-ja": "你是一台翻译机。只输出译文。中文译成日语,日语译成中文;其它语言默认译成中文。",
|
}
|
|
CHAT_PROACTIVE_HINT = (
|
"你是用户的语音伙伴。进入对话后可以先主动短句邀请聊天,例如「我们来聊天吧」。"
|
"用户开口后按正常对话回答,不要反复催促。"
|
)
|
|
CHAT_RETURN_TOPICS = ("今天过得怎么样", "你最近在忙什么", "有什么新鲜事")
|
|
DEFAULT_SHADOWING_SENTENCES = (
|
"Hello, how are you today?",
|
"I like to read English books.",
|
"The weather is nice this morning.",
|
"Can you say that again, please?",
|
"Practice a little every day.",
|
)
|
|
DEVICE_PLAY_TOOL = "self_mp3_play"
|
DEVICE_STOP_TOOL = "self_mp3_stop"
|
DEVICE_LIST_TOOL = "self_mp3_get_list"
|
DEVICE_ENSURE_APP_TOOL = "self_sd_ensure_app"
|
SD_LAYOUT_MODES = ("PLAY_AUDIO", "SHADOWING")
|
SHADOWING_LISTEN_RATIO = 1.2
|
SHADOWING_LISTEN_MIN_MS = 800
|
REPEATER_END_PHRASE = "泛听结束"
|
SHADOWING_END_PHRASE = "跟读结束"
|
REPEATER_START_PHRASE = "开始泛听任务"
|
SHADOWING_START_PHRASE = "开始跟读任务"
|
CHAT_START_PHRASE = "开始对话任务"
|
TRANSLATION_START_PHRASE = "开始翻译任务"
|
BILINGUAL_START_PHRASE = "开始中英对话任务"
|
|
|
def source_priority(source: str) -> int:
|
return SOURCE_PRIORITY.get(source or "voice", 1)
|
|
|
def _ensure_runtime_fields(conn: "ConnectionHandler") -> None:
|
if not hasattr(conn, "active_app_id"):
|
conn.active_app_id = default_app_id()
|
if not hasattr(conn, "current_task_id"):
|
conn.current_task_id = "conversation"
|
if not hasattr(conn, "interaction_mode"):
|
conn.interaction_mode = "CHAT"
|
if not hasattr(conn, "current_source"):
|
conn.current_source = "default_chat"
|
if not hasattr(conn, "tool_policy"):
|
conn.tool_policy = "inherit_agent"
|
if not hasattr(conn, "tool_allowlist"):
|
conn.tool_allowlist = []
|
if not hasattr(conn, "original_system_prompt"):
|
conn.original_system_prompt = conn.prompt
|
if not hasattr(conn, "shadowing_index"):
|
conn.shadowing_index = 0
|
if not hasattr(conn, "shadowing_prompt"):
|
conn.shadowing_prompt = ""
|
if not hasattr(conn, "shadowing_clips"):
|
conn.shadowing_clips = []
|
if not hasattr(conn, "shadowing_awaiting_user"):
|
conn.shadowing_awaiting_user = False
|
if not hasattr(conn, "shadowing_clip_gen"):
|
conn.shadowing_clip_gen = 0
|
if not hasattr(conn, "shadowing_timeout_task"):
|
conn.shadowing_timeout_task = None
|
if not hasattr(conn, "last_asr_language"):
|
conn.last_asr_language = None
|
if not hasattr(conn, "task_session"):
|
conn.task_session = None
|
if not hasattr(conn, "last_asr_audio_snapshot"):
|
conn.last_asr_audio_snapshot = None
|
if not hasattr(conn, "shadowing_listen_pcm"):
|
conn.shadowing_listen_pcm = []
|
if not hasattr(conn, "shadowing_audio_saved_index"):
|
conn.shadowing_audio_saved_index = -1
|
if not hasattr(conn, "ensured_app_dirs"):
|
conn.ensured_app_dirs = set()
|
if not hasattr(conn, "repeater_speed_queue"):
|
conn.repeater_speed_queue = []
|
if not hasattr(conn, "repeater_play_name"):
|
conn.repeater_play_name = ""
|
if not hasattr(conn, "repeater_power_off_after"):
|
conn.repeater_power_off_after = False
|
if not hasattr(conn, "repeater_playlist"):
|
conn.repeater_playlist = []
|
if not hasattr(conn, "shadowing_last_speed"):
|
conn.shadowing_last_speed = 1.0
|
if not hasattr(conn, "shadowing_assess_index"):
|
conn.shadowing_assess_index = 0
|
if not hasattr(conn, "chat_greeted"):
|
conn.chat_greeted = False
|
if not hasattr(conn, "pending_play_after_tts"):
|
conn.pending_play_after_tts = None
|
|
|
def _task_needs_sd_layout(task: Optional[Dict[str, Any]]) -> bool:
|
if not task:
|
return False
|
mode = task.get("interaction_mode") or ""
|
if mode in SD_LAYOUT_MODES:
|
return True
|
for skill in task.get("skills") or []:
|
name = (skill or {}).get("name") or ""
|
if name in ("self.mp3.play", DEVICE_PLAY_TOOL):
|
return True
|
return False
|
|
|
def app_sd_layout_paths(app_id: str, app: Optional[Dict[str, Any]] = None) -> list:
|
"""Return SD lesson dirs for an App: /{app_id}/{task_id}/lessons."""
|
if not app_id:
|
return []
|
if app is None:
|
app = get_app(app_id)
|
if not app:
|
return []
|
paths = []
|
for task_id, task in (app.get("tasks") or {}).items():
|
if _task_needs_sd_layout(task):
|
paths.append(f"/{app_id}/{task_id}/lessons")
|
return paths
|
|
|
def _mcp_layout_ok(result) -> bool:
|
if result is None:
|
return False
|
if result.action in (Action.ERROR, Action.NOTFOUND):
|
return False
|
blob = str(getattr(result, "response", None) or getattr(result, "result", None) or "")
|
compact = blob.replace(" ", "").lower()
|
if '"ok":false' in compact:
|
return False
|
return True
|
|
|
async def ensure_app_sd_dirs(conn: "ConnectionHandler", app_id: str) -> bool:
|
"""Create /{app} and /{app}/{task}/lessons on device SD. Never raises; missing SD is ignored."""
|
_ensure_runtime_fields(conn)
|
app = get_app(app_id)
|
paths = app_sd_layout_paths(app_id, app)
|
if not paths:
|
return True
|
already = getattr(conn, "ensured_app_dirs", None)
|
if already is None:
|
conn.ensured_app_dirs = set()
|
already = conn.ensured_app_dirs
|
if app_id in already:
|
return True
|
try:
|
root = await _execute_named_tool(conn, DEVICE_ENSURE_APP_TOOL, {"app_id": app_id, "task_id": ""})
|
if not _mcp_layout_ok(root):
|
logger.bind(tag=TAG).warning(f"创建设备 App 目录失败 {app_id}: {getattr(root, 'response', root)}")
|
return False
|
for task_id, task in (app.get("tasks") or {}).items():
|
if not _task_needs_sd_layout(task):
|
continue
|
result = await _execute_named_tool(
|
conn, DEVICE_ENSURE_APP_TOOL, {"app_id": app_id, "task_id": task_id}
|
)
|
if not _mcp_layout_ok(result):
|
logger.bind(tag=TAG).warning(
|
f"创建设备 Task 目录失败 {app_id}/{task_id}: {getattr(result, 'response', result)}"
|
)
|
return False
|
already.add(app_id)
|
logger.bind(tag=TAG).info(f"已确保 SD 目录 {paths}")
|
return True
|
except Exception as e:
|
logger.bind(tag=TAG).warning(f"创建设备 App 目录异常 {app_id}: {e}")
|
return False
|
|
|
async def ensure_all_subscribed_app_sd_dirs(conn: "ConnectionHandler") -> None:
|
"""After MCP tools/list, mkdir for every registry App that needs SD. Failures do not drop WS."""
|
try:
|
apps = (get_registry().get("apps") or {})
|
for app_id, app in apps.items():
|
if app_sd_layout_paths(app_id, app):
|
await ensure_app_sd_dirs(conn, app_id)
|
except Exception as e:
|
logger.bind(tag=TAG).warning(f"订阅 App 建目录失败(忽略): {e}")
|
|
|
def schedule_ensure_subscribed_app_sd_dirs(conn: "ConnectionHandler") -> None:
|
"""Fire-and-forget so tools/list handling is not blocked waiting for MCP mkdir."""
|
|
async def _run():
|
await ensure_all_subscribed_app_sd_dirs(conn)
|
|
try:
|
loop = getattr(conn, "loop", None)
|
if loop:
|
loop.create_task(_run())
|
else:
|
asyncio.create_task(_run())
|
except Exception as e:
|
logger.bind(tag=TAG).warning(f"无法调度 App 建目录: {e}")
|
|
|
def filter_tools(conn: "ConnectionHandler", tools: Dict[str, Any]) -> Dict[str, Any]:
|
"""inherit_agent keeps the live agent toolset; allowlist keeps named tools only."""
|
_ensure_runtime_fields(conn)
|
if getattr(conn, "tool_policy", "inherit_agent") != "allowlist":
|
return tools
|
allow = set(getattr(conn, "tool_allowlist", None) or [])
|
if not allow:
|
return {}
|
filtered = {}
|
for name, definition in tools.items():
|
if name in allow:
|
filtered[name] = definition
|
return filtered
|
|
|
def _refresh_tools(conn: "ConnectionHandler") -> None:
|
handler = getattr(conn, "func_handler", None)
|
if handler and hasattr(handler, "upload_functions_desc"):
|
handler.upload_functions_desc()
|
|
|
def _normalize_language_pair(raw: Any) -> str:
|
pair = str(raw or "zh-en").strip().lower().replace("_", "-")
|
if pair in ("cn-en", "zh-cn-en", "zhcn-en"):
|
return "zh-en"
|
return pair or "zh-en"
|
|
|
def _apply_prompt(conn: "ConnectionHandler", app: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> None:
|
mode = app.get("system_prompt_mode") or "none"
|
if conn.original_system_prompt is None:
|
conn.original_system_prompt = conn.prompt
|
pair = _normalize_language_pair((params or {}).get("language_pair"))
|
conn.language_pair = pair
|
conn.language_pair_fallback = pair not in BILINGUAL_PAIR_PROMPTS and pair not in TRANSLATOR_PAIR_PROMPTS
|
prompt_pair = "zh-en" if conn.language_pair_fallback else pair
|
if mode == "translator":
|
pair_prompt = TRANSLATOR_PAIR_PROMPTS.get(prompt_pair, TRANSLATOR_PROMPT)
|
conn.change_system_prompt(
|
pair_prompt
|
+ "用户说话后应调用 translate;用户说退出翻译、回到聊天时调用 switch_app,app_id=chat。"
|
"不要讲笑话、不要查天气、不要闲聊。"
|
)
|
elif mode == "bilingual":
|
conn.change_system_prompt(BILINGUAL_PAIR_PROMPTS.get(prompt_pair, BILINGUAL_PROMPT))
|
elif mode == "agent_proactive":
|
base = conn.original_system_prompt or conn.prompt or ""
|
if CHAT_PROACTIVE_HINT not in (base or ""):
|
conn.change_system_prompt((base + "\n" + CHAT_PROACTIVE_HINT).strip())
|
else:
|
conn.change_system_prompt(base)
|
else:
|
if conn.original_system_prompt:
|
conn.change_system_prompt(conn.original_system_prompt)
|
|
|
async def _execute_named_tool(conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any]):
|
handler = getattr(conn, "func_handler", None)
|
if not handler or not hasattr(handler, "tool_manager"):
|
return ActionResponse(action=Action.ERROR, response="工具处理器未就绪")
|
try:
|
return await handler.tool_manager.execute_tool(tool_name, arguments or {})
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"执行工具 {tool_name} 失败: {e}")
|
return ActionResponse(action=Action.ERROR, response=str(e))
|
|
|
async def _stop_current_skills(conn: "ConnectionHandler") -> None:
|
mode = getattr(conn, "interaction_mode", "CHAT")
|
_cancel_shadowing_timeout(conn)
|
if mode in ("PLAY_AUDIO", "SHADOWING"):
|
await _execute_named_tool(conn, DEVICE_STOP_TOOL, {})
|
conn.awaiting_voice_app_choice = False
|
conn.pending_play_after_tts = None
|
conn.shadowing_index = 0
|
conn.shadowing_prompt = ""
|
conn.shadowing_clips = []
|
conn.shadowing_awaiting_user = False
|
conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
|
conn.shadowing_assess_index = 0
|
conn.shadowing_listen_pcm = []
|
conn.shadowing_audio_saved_index = -1
|
conn.repeater_speed_queue = []
|
conn.repeater_play_name = ""
|
conn.repeater_power_off_after = False
|
conn.repeater_playlist = []
|
|
|
def tts_channels_ready(conn: "ConnectionHandler") -> bool:
|
tts = getattr(conn, "tts", None)
|
if tts is None:
|
return False
|
play = getattr(tts, "audio_play_priority_thread", None)
|
text_th = getattr(tts, "tts_priority_thread", None)
|
return bool(
|
play is not None
|
and play.is_alive()
|
and text_th is not None
|
and text_th.is_alive()
|
)
|
|
|
async def wait_for_tts_channels(conn: "ConnectionHandler", timeout: float = 8.0) -> bool:
|
loop = asyncio.get_running_loop()
|
start = loop.time()
|
while (loop.time() - start) < timeout:
|
if tts_channels_ready(conn):
|
return True
|
await asyncio.sleep(0.1)
|
return tts_channels_ready(conn)
|
|
|
def _speak(conn: "ConnectionHandler", text: str) -> None:
|
if not text:
|
return
|
tts = getattr(conn, "tts", None)
|
if tts is None:
|
logger.bind(tag=TAG).warning("TTS未初始化,跳过播报")
|
return
|
if not tts_channels_ready(conn):
|
logger.bind(tag=TAG).warning("TTS通道未就绪,跳过播报")
|
return
|
from core.handle.intentHandler import speak_txt
|
|
if not getattr(conn, "sentence_id", None):
|
conn.sentence_id = uuid.uuid4().hex
|
try:
|
speak_txt(conn, text)
|
except Exception as e:
|
logger.bind(tag=TAG).warning(f"主动播报失败: {e}")
|
|
|
async def _play_bind_prompt(conn: "ConnectionHandler") -> None:
|
from core.handle.receiveAudioHandle import check_bind_device
|
|
await check_bind_device(conn)
|
|
|
async def prompt_bind_if_needed(conn: "ConnectionHandler") -> bool:
|
"""未绑定设备时播 6 位绑定码,并阻止欢迎词/进场词。已绑定则返回 False。"""
|
event = getattr(conn, "bind_completed_event", None)
|
if event is not None and not event.is_set():
|
try:
|
await asyncio.wait_for(event.wait(), timeout=8)
|
except asyncio.TimeoutError:
|
pass
|
if not getattr(conn, "need_bind", False):
|
return False
|
if not await wait_for_tts_channels(conn, timeout=8):
|
logger.bind(tag=TAG).warning("TTS通道未就绪,仍尝试播放绑定码")
|
await _play_bind_prompt(conn)
|
return True
|
|
|
def _greeting_text(conn: "ConnectionHandler") -> str:
|
return (conn.config or {}).get("proactive_chat_greeting") or "我们来聊天吧。"
|
|
|
def _display_name(app: Optional[Dict[str, Any]], task: Optional[Dict[str, Any]] = None) -> str:
|
if task and task.get("display_name"):
|
return str(task["display_name"])
|
if app and app.get("display_name"):
|
return str(app["display_name"])
|
if app and app.get("name"):
|
return str(app["name"])
|
return "当前模式"
|
|
|
def _enter_prompt(app: Dict[str, Any], task: Dict[str, Any]) -> str:
|
return (task.get("enter_prompt") or app.get("enter_prompt") or "").strip()
|
|
|
def _switch_announcement(
|
prev_app: Optional[Dict[str, Any]],
|
prev_task: Optional[Dict[str, Any]],
|
app: Dict[str, Any],
|
task: Dict[str, Any],
|
app_id: str,
|
) -> str:
|
if app_id == "chat":
|
prev_name = _display_name(prev_app, prev_task)
|
topic = random.choice(CHAT_RETURN_TOPICS)
|
return f"已经退出{prev_name},现在是对话模式,我们来聊点{topic}吧。"
|
return _enter_prompt(app, task) or f"现在已经切换至{_display_name(app, task)}。"
|
|
|
def _shadowing_sentences(conn: "ConnectionHandler") -> list:
|
cfg = (conn.config or {}).get("app_runtime") or {}
|
sentences = cfg.get("shadowing_sentences")
|
if not sentences:
|
_, task = get_task("english_learning", "shadowing")
|
sentences = (task or {}).get("sentences")
|
if not sentences:
|
sentences = DEFAULT_SHADOWING_SENTENCES
|
return [str(s).strip() for s in sentences if str(s).strip()]
|
|
|
def lesson_mp3_clips(names, app_id: str, task_id: str, lesson_id: str = "") -> list:
|
"""Pick sentence MP3s under /{app}/{task}/lessons/{lesson}/..."""
|
prefix = f"/{app_id}/{task_id}/lessons/"
|
clips = []
|
for raw in names or []:
|
name = str(raw or "").replace("\\", "/")
|
if not name.lower().endswith(".mp3"):
|
continue
|
if not name.startswith("/"):
|
name = "/" + name.lstrip("/")
|
if not name.startswith(prefix):
|
continue
|
rest = name[len(prefix) :]
|
if not rest:
|
continue
|
base = rest.rsplit("/", 1)[-1].lower()
|
if base == "full.mp3" or base.endswith("_full.mp3"):
|
continue
|
if lesson_id:
|
if rest != lesson_id and not rest.startswith(str(lesson_id) + "/"):
|
continue
|
clips.append(name)
|
clips.sort()
|
if lesson_id or not clips:
|
return clips
|
first_lesson = clips[0][len(prefix) :].split("/", 1)[0]
|
return [
|
name
|
for name in clips
|
if name[len(prefix) :] == first_lesson
|
or name[len(prefix) :].startswith(first_lesson + "/")
|
]
|
|
|
def repeater_lesson_mp3(names, app_id: str, task_id: str) -> str:
|
"""Pick the full-lesson MP3 under /{app}/{task}/lessons/, else the first file there."""
|
prefix = f"/{app_id}/{task_id}/lessons/"
|
fulls = []
|
others = []
|
for raw in names or []:
|
name = str(raw or "").replace("\\", "/")
|
if not name.lower().endswith(".mp3"):
|
continue
|
if not name.startswith("/"):
|
name = "/" + name.lstrip("/")
|
if not name.startswith(prefix):
|
continue
|
base = name.rsplit("/", 1)[-1].lower()
|
if base == "full.mp3" or base.endswith("_full.mp3"):
|
fulls.append(name)
|
else:
|
others.append(name)
|
fulls.sort()
|
others.sort()
|
if fulls:
|
return fulls[0]
|
return others[0] if others else ""
|
|
|
def _is_clip_path(text: str) -> bool:
|
t = (text or "").replace("\\", "/").lower()
|
return t.endswith(".mp3") or t.startswith("/")
|
|
|
def _shadowing_items(conn: "ConnectionHandler") -> list:
|
clips = getattr(conn, "shadowing_clips", None) or []
|
if clips:
|
return list(clips)
|
return _shadowing_sentences(conn)
|
|
|
def _begin_shadowing(conn: "ConnectionHandler", enter_text: str) -> str:
|
sentences = _shadowing_sentences(conn)
|
conn.shadowing_index = 0
|
if not sentences:
|
conn.shadowing_prompt = ""
|
return enter_text or "现在已经切换至跟读模式,但还没有跟读句子。"
|
conn.shadowing_prompt = sentences[0]
|
prefix = enter_text or "现在已经切换至跟读模式。"
|
return f"{prefix}请跟我读:{sentences[0]}"
|
|
|
def _cancel_shadowing_timeout(conn: "ConnectionHandler") -> None:
|
task = getattr(conn, "shadowing_timeout_task", None)
|
if task is not None:
|
try:
|
if not task.done():
|
task.cancel()
|
except Exception:
|
pass
|
conn.shadowing_timeout_task = None
|
|
|
def _task_for(conn: "ConnectionHandler") -> Optional[Dict[str, Any]]:
|
session = current_session(conn) or getattr(conn, "task_session", None)
|
app_id = getattr(conn, "active_app_id", None) or getattr(session, "app_id", None) or "english_learning"
|
task_id = getattr(conn, "current_task_id", None) or getattr(session, "task_id", None) or "shadowing"
|
_, task = get_task(app_id, task_id)
|
return task
|
|
|
def _expected_for_index(conn: "ConnectionHandler", index: int) -> Optional[str]:
|
session = current_session(conn) or getattr(conn, "task_session", None)
|
app_id = getattr(conn, "active_app_id", None) or getattr(session, "app_id", "") or ""
|
task_id = getattr(conn, "current_task_id", None) or getattr(session, "task_id", "") or ""
|
clips = list(getattr(conn, "shadowing_clips", None) or [])
|
return resolve_expected_text(
|
_task_for(conn),
|
clips,
|
index,
|
transcript_cache_dir(app_id, task_id),
|
)
|
|
|
def apply_deferred_assessment(
|
conn: "ConnectionHandler",
|
step_index: int,
|
session: Any = None,
|
clips: Optional[list] = None,
|
app_id: str = "",
|
task_id: str = "",
|
audio_snapshot: Any = None,
|
persist_audio: bool = True,
|
) -> None:
|
"""Write expected_text + metrics onto the captured session. Never raises into play."""
|
import time as _time
|
|
session = session or getattr(conn, "task_session", None)
|
if not session:
|
return
|
app_id = app_id or getattr(conn, "active_app_id", None) or getattr(session, "app_id", "") or ""
|
task_id = task_id or getattr(conn, "current_task_id", None) or getattr(session, "task_id", "") or ""
|
_, task = get_task(app_id or "english_learning", task_id or "shadowing")
|
cfg = (task or {}).get("assessment") or {}
|
clip_list = list(clips if clips is not None else (getattr(conn, "shadowing_clips", None) or []))
|
turn = session.find_turn(step_index)
|
asr = _plain_user_text((turn or {}).get("asr_text") or "")
|
expected = (turn or {}).get("expected_text") or resolve_expected_text(
|
task, clip_list, step_index, transcript_cache_dir(app_id, task_id)
|
)
|
snapshot = audio_snapshot
|
if snapshot is None:
|
snapshot = getattr(conn, "last_asr_audio_snapshot", None)
|
wav_bytes = pcm_snapshot_to_wav(snapshot)
|
existing_audio = (turn or {}).get("audio_id")
|
audio_id = existing_audio
|
if persist_audio and not existing_audio:
|
audio_id = save_turn_wav(
|
getattr(session, "task_session_id", "") or "",
|
step_index,
|
wav_bytes,
|
conn=conn,
|
text=asr,
|
)
|
elif audio_id and asr:
|
try:
|
from core.audio_archive import write_sidecar_for_relative
|
|
write_sidecar_for_relative(conn, audio_id, asr)
|
except Exception:
|
pass
|
try:
|
from core.handle.reportHandle import enqueue_asr_report
|
|
enqueue_asr_report(
|
conn,
|
asr or "",
|
snapshot,
|
getattr(session, "task_session_id", None),
|
int(step_index),
|
)
|
except Exception:
|
pass
|
clip = ""
|
if 0 <= int(step_index) < len(clip_list):
|
clip = str(clip_list[step_index] or "")
|
metrics = []
|
if not (cfg and cfg.get("enabled") is False):
|
metrics = evaluate_turn(
|
asr,
|
expected or "",
|
wav_bytes,
|
cfg,
|
extra={"clip": clip, "step_index": step_index},
|
)
|
session.upsert_turn(
|
index=step_index,
|
prompt_text=(turn or {}).get("prompt_text") or getattr(conn, "shadowing_prompt", "") or "",
|
expected_text=expected,
|
metrics=metrics or None,
|
assessed_at=int(_time.time() * 1000) if metrics else None,
|
audio_id=audio_id,
|
)
|
session.save()
|
|
|
def _schedule_deferred_assessment(conn: "ConnectionHandler", step_index: int) -> None:
|
session = getattr(conn, "task_session", None)
|
clips = list(getattr(conn, "shadowing_clips", None) or [])
|
app_id = getattr(conn, "active_app_id", None) or getattr(session, "app_id", "") or ""
|
task_id = getattr(conn, "current_task_id", None) or getattr(session, "task_id", "") or ""
|
audio_snapshot = getattr(conn, "last_asr_audio_snapshot", None)
|
|
async def _run():
|
try:
|
apply_deferred_assessment(
|
conn,
|
step_index,
|
session=session,
|
clips=clips,
|
app_id=app_id,
|
task_id=task_id,
|
audio_snapshot=audio_snapshot,
|
persist_audio=False,
|
)
|
except Exception as exc:
|
logger.bind(tag=TAG).warning(f"跟读后评估失败: {exc}")
|
|
loop = getattr(conn, "loop", None)
|
try:
|
loop = loop or asyncio.get_running_loop()
|
loop.create_task(_run())
|
except RuntimeError:
|
try:
|
apply_deferred_assessment(
|
conn,
|
step_index,
|
session=session,
|
clips=clips,
|
app_id=app_id,
|
task_id=task_id,
|
audio_snapshot=audio_snapshot,
|
persist_audio=False,
|
)
|
except Exception as exc:
|
logger.bind(tag=TAG).warning(f"跟读后评估失败: {exc}")
|
|
|
def _schedule_shadowing_listen_timeout(conn: "ConnectionHandler", duration_ms: int) -> None:
|
_cancel_shadowing_timeout(conn)
|
ratio = resolve_listen_ratio(conn, _task_for(conn))
|
wait_ms = listen_window_ms(
|
duration_ms or 0,
|
ratio,
|
SHADOWING_LISTEN_MIN_MS,
|
speed=getattr(conn, "shadowing_last_speed", None),
|
wall_clock=True,
|
)
|
conn.shadowing_awaiting_user = True
|
conn.shadowing_assess_index = int(getattr(conn, "shadowing_index", 0) or 0)
|
conn.shadowing_audio_saved_index = -1
|
conn.shadowing_listen_pcm = []
|
gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0)
|
logger.bind(tag=TAG).info(
|
f"跟读听窗 {wait_ms}ms = 实际播放 {duration_ms}ms × {ratio}"
|
f"(倍速已含在实际播放时长里) gen={gen}"
|
)
|
|
async def _fire():
|
try:
|
await asyncio.sleep(wait_ms / 1000.0)
|
except asyncio.CancelledError:
|
return
|
if int(getattr(conn, "shadowing_clip_gen", 0) or 0) != gen:
|
return
|
if getattr(conn, "interaction_mode", "") != "SHADOWING":
|
return
|
if not getattr(conn, "shadowing_awaiting_user", False):
|
return
|
await _on_shadowing_listen_window_closed(conn)
|
|
loop = getattr(conn, "loop", None)
|
if loop:
|
conn.shadowing_timeout_task = loop.create_task(_fire())
|
else:
|
conn.shadowing_timeout_task = asyncio.create_task(_fire())
|
|
|
async def close_shadowing_listen_window(conn: "ConnectionHandler") -> None:
|
"""Device listen stop: archive this step once, cancel the clock, advance index."""
|
_cancel_shadowing_timeout(conn)
|
await _on_shadowing_listen_window_closed(conn)
|
|
|
def _drain_asr_queue_into_shadowing_pcm(conn: "ConnectionHandler") -> int:
|
"""Binary opus is queued; listen stop/timeout run on the WS loop first. Take pending PCM now."""
|
q = getattr(conn, "asr_audio_queue", None)
|
if q is None or not getattr(conn, "shadowing_awaiting_user", False):
|
return 0
|
drained = 0
|
while True:
|
try:
|
frame = q.get_nowait()
|
except (queue.Empty, EOFError, OSError):
|
break
|
if frame:
|
append_shadowing_listen_pcm(conn, frame)
|
drained += 1
|
if drained:
|
logger.bind(tag=TAG).info(f"跟读关窗前抽干 ASR 队列 frames={drained}")
|
return drained
|
|
|
def append_shadowing_listen_pcm(conn: "ConnectionHandler", pcm_frame: Any) -> None:
|
if getattr(conn, "interaction_mode", "") != "SHADOWING":
|
return
|
if not getattr(conn, "shadowing_awaiting_user", False):
|
return
|
if not pcm_frame:
|
return
|
buf = getattr(conn, "shadowing_listen_pcm", None)
|
if buf is None:
|
conn.shadowing_listen_pcm = []
|
buf = conn.shadowing_listen_pcm
|
buf.append(pcm_frame)
|
|
|
def _take_shadowing_listen_pcm(conn: "ConnectionHandler") -> list:
|
frames = list(getattr(conn, "shadowing_listen_pcm", None) or [])
|
conn.shadowing_listen_pcm = []
|
return frames
|
|
|
def save_shadowing_listen_audio(conn: "ConnectionHandler", step_index: Optional[int] = None) -> None:
|
"""Persist exactly one wav per follow-read step. Never merge neighboring windows."""
|
if getattr(conn, "interaction_mode", "") != "SHADOWING":
|
return
|
index = int(
|
step_index
|
if step_index is not None
|
else (getattr(conn, "shadowing_assess_index", getattr(conn, "shadowing_index", 0)) or 0)
|
)
|
if int(getattr(conn, "shadowing_audio_saved_index", -1)) == index:
|
conn.shadowing_listen_pcm = []
|
return
|
snapshot = _take_shadowing_listen_pcm(conn)
|
if not snapshot:
|
logger.bind(tag=TAG).warning(f"跟读听窗无 PCM,写静音占位 step={index}")
|
snapshot = silent_pcm_snapshot()
|
conn.shadowing_audio_saved_index = index
|
from core.app_runtime.session import stash_asr_audio
|
|
stash_asr_audio(conn, snapshot)
|
try:
|
apply_deferred_assessment(conn, index, audio_snapshot=snapshot)
|
except Exception as exc:
|
logger.bind(tag=TAG).warning(f"跟读听窗录音落盘失败 step={index}: {exc}")
|
|
|
async def _on_shadowing_listen_window_closed(conn: "ConnectionHandler") -> None:
|
"""Clock or listen stop: archive this listen. Device already sequences play → record → play."""
|
if not getattr(conn, "shadowing_awaiting_user", False):
|
return
|
_drain_asr_queue_into_shadowing_pcm(conn)
|
conn.shadowing_awaiting_user = False
|
listened_idx = int(getattr(conn, "shadowing_index", 0) or 0)
|
listened_prompt = getattr(conn, "shadowing_prompt", "") or ""
|
save_shadowing_listen_audio(conn, listened_idx)
|
items = _shadowing_items(conn)
|
nxt = listened_idx + 1
|
conn.shadowing_index = nxt
|
if nxt < len(items):
|
conn.shadowing_prompt = items[nxt]
|
logger.bind(tag=TAG).info(f"跟读:听窗结束 step={listened_idx}")
|
try:
|
session = current_session(conn) or getattr(conn, "task_session", None)
|
if session:
|
if session.find_turn(listened_idx) is None:
|
session.upsert_turn(
|
index=listened_idx,
|
prompt_text=listened_prompt,
|
asr_text="",
|
expected_text=_expected_for_index(conn, listened_idx),
|
)
|
session.add_event("listen_window_closed")
|
session.add_event("listen_timeout")
|
session.save()
|
except Exception as exc:
|
logger.bind(tag=TAG).warning(f"跟读听窗归档失败: {exc}")
|
|
|
def _parse_mp3_list_names(raw: Any) -> list:
|
text = ""
|
if raw is None:
|
return []
|
if hasattr(raw, "response") or hasattr(raw, "result"):
|
text = str(getattr(raw, "response", None) or getattr(raw, "result", None) or "")
|
else:
|
text = str(raw)
|
text = text.strip()
|
if not text:
|
return []
|
try:
|
import json
|
|
data = json.loads(text)
|
except Exception:
|
return []
|
files = data.get("files") if isinstance(data, dict) else None
|
names = []
|
if isinstance(files, list):
|
for item in files:
|
if isinstance(item, dict) and item.get("name"):
|
names.append(str(item["name"]))
|
elif isinstance(item, str):
|
names.append(item)
|
return names
|
|
|
async def _list_sd_mp3_names(conn: "ConnectionHandler") -> list:
|
result = await _execute_named_tool(conn, DEVICE_LIST_TOOL, {})
|
return _parse_mp3_list_names(result)
|
|
|
async def _list_sd_mp3_names_safe(conn: "ConnectionHandler"):
|
try:
|
names = await _list_sd_mp3_names(conn)
|
return names if isinstance(names, list) else []
|
except Exception as exc:
|
logger.bind(tag=TAG).warning(f"列举 SD 课文失败: {exc}")
|
return None
|
|
|
def _play_lesson_names(play_params: Optional[Dict[str, Any]]) -> list:
|
from .lesson_audio import unique_lesson_names
|
|
if not isinstance(play_params, dict):
|
return []
|
items = play_params.get("items")
|
if isinstance(items, list) and items:
|
return unique_lesson_names(items)
|
return unique_lesson_names([play_params])
|
|
|
def _lesson_sync_timeout_sec(conn: "ConnectionHandler") -> float:
|
runtime = {}
|
cfg = getattr(conn, "config", None)
|
if isinstance(cfg, dict) and isinstance(cfg.get("app_runtime"), dict):
|
runtime = cfg.get("app_runtime") or {}
|
raw = runtime.get("lesson_sync_timeout_sec", 90)
|
try:
|
return max(0.0, float(raw))
|
except (TypeError, ValueError):
|
return 90.0
|
|
|
async def _request_lesson_sync(
|
conn: "ConnectionHandler",
|
missing: list,
|
task_id: str,
|
params: Optional[Dict[str, Any]],
|
) -> None:
|
from config.manage_api_client import sync_lesson_audio
|
from .lesson_audio import conn_org_id, course_id_for, lesson_audio_base_url, unique_lesson_names
|
|
files = unique_lesson_names(missing)
|
if not files:
|
return
|
params = params or {}
|
lesson_id = str(params.get("lesson_id") or params.get("lesson") or "l1.001").strip() or "l1.001"
|
org_id = conn_org_id(conn, params)
|
payload = {
|
"macAddress": getattr(conn, "device_id", None) or "",
|
"courseId": course_id_for(task_id, lesson_id),
|
"orgId": org_id,
|
"baseUrl": lesson_audio_base_url(getattr(conn, "config", None), org_id),
|
"files": files,
|
}
|
result = await sync_lesson_audio(payload)
|
if result is None:
|
logger.bind(tag=TAG).warning(f"课文 MQTT 同步失败 files={len(files)}")
|
else:
|
logger.bind(tag=TAG).info(f"课文 MQTT 同步已下发 org={org_id} files={len(files)}")
|
|
|
async def _wait_lesson_files(conn: "ConnectionHandler", needed: list, timeout_sec: float) -> bool:
|
from .lesson_audio import missing_lesson_names
|
|
if not needed:
|
return False
|
deadline = asyncio.get_running_loop().time() + max(0.0, timeout_sec)
|
while True:
|
present = await _list_sd_mp3_names_safe(conn)
|
if present is not None and not missing_lesson_names(needed, present):
|
return True
|
remaining = deadline - asyncio.get_running_loop().time()
|
if remaining <= 0:
|
return False
|
await asyncio.sleep(min(2.0, remaining))
|
|
|
async def _ensure_lesson_mp3s(
|
conn: "ConnectionHandler",
|
names: list,
|
task_id: str,
|
params: Optional[Dict[str, Any]],
|
) -> bool:
|
"""READY if SD already has the package. Otherwise PRELOAD via MQTT then wait. Device paths have no org."""
|
from .lesson_audio import missing_lesson_names, unique_lesson_names
|
|
needed = unique_lesson_names(names)
|
if not needed:
|
return False
|
present = await _list_sd_mp3_names_safe(conn)
|
if present is None:
|
logger.bind(tag=TAG).warning("无法列举 SD 课文,跳过同步以免误报")
|
return True
|
missing = missing_lesson_names(needed, present)
|
if not missing:
|
return True
|
params = params or {}
|
conn.lesson_syncing = True
|
_speak(conn, "正在同步音频,请稍等。")
|
await _request_lesson_sync(conn, missing, task_id, params)
|
timeout = _lesson_sync_timeout_sec(conn)
|
ready = await _wait_lesson_files(conn, needed, timeout)
|
conn.lesson_syncing = False
|
return ready
|
|
|
def _is_full_lesson_mp3(name: str) -> bool:
|
base = str(name or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
|
return base == "full.mp3" or base.endswith("_full.mp3")
|
|
|
def _compact_shadowing_playlist(
|
conn: "ConnectionHandler",
|
params: Optional[Dict[str, Any]],
|
) -> List[Dict[str, Any]]:
|
"""Keep {name, repeat, speed}. Device expands repeats locally with a listen window each pass."""
|
items = playlist_from_params(_task_for(conn), params, "shadowing", default_shadowing_playlist)
|
compact: List[Dict[str, Any]] = []
|
for raw in items:
|
if not isinstance(raw, dict):
|
continue
|
name = str(raw.get("name") or "")
|
if not name or _is_full_lesson_mp3(name):
|
continue
|
try:
|
repeat_n = int(raw.get("repeat") or 1)
|
except (TypeError, ValueError):
|
repeat_n = 1
|
if repeat_n < 1:
|
repeat_n = 1
|
compact.append(
|
{
|
"name": name,
|
"repeat": repeat_n,
|
"speed": clamp_play_speed(
|
raw["speed"] if raw.get("speed") is not None else 1.0
|
),
|
"power_off_after": False,
|
"finish_task": False,
|
}
|
)
|
return compact
|
|
|
def _begin_shadowing_playlist(
|
conn: "ConnectionHandler",
|
params: Optional[Dict[str, Any]],
|
) -> Dict[str, Any]:
|
"""One shot: send the whole shadowing playlist. Device plays, records, plays."""
|
items = _compact_shadowing_playlist(conn, params)
|
clips = expand_playlist_plays(items)
|
conn.shadowing_clips = clips
|
conn.shadowing_index = 0
|
conn.shadowing_prompt = clips[0] if clips else ""
|
conn.shadowing_awaiting_user = False
|
first = items[0] if items else dict(params or {})
|
play = dict(first)
|
play["items"] = items
|
play["finish_task"] = False
|
play["listen_ratio"] = resolve_listen_ratio(conn, _task_for(conn))
|
if items:
|
conn.shadowing_last_speed = items[0].get("speed")
|
return play
|
|
|
async def _play_shadowing_clip(conn: "ConnectionHandler", name: str) -> bool:
|
if getattr(conn, "interaction_mode", "") != "SHADOWING":
|
logger.bind(tag=TAG).info(f"跳过跟读播放,当前已离开跟读: {name}")
|
return False
|
conn.shadowing_awaiting_user = False
|
conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
|
speed = resolve_play_speed(_task_for(conn))
|
conn.shadowing_last_speed = speed
|
notice = await _execute_named_tool_play(
|
conn,
|
{
|
"name": name,
|
"repeat": 1,
|
"power_off_after": False,
|
"speed": speed,
|
"finish_task": False,
|
"listen_ratio": resolve_listen_ratio(conn, _task_for(conn)),
|
},
|
)
|
if notice and notice.action in (Action.ERROR, Action.NOTFOUND):
|
return False
|
return True
|
|
|
async def _prepare_shadowing_sd(conn: "ConnectionHandler", params: Optional[Dict[str, Any]]) -> bool:
|
params = params or {}
|
items = _compact_shadowing_playlist(conn, params)
|
clips = expand_playlist_plays(items)
|
if not clips:
|
lesson = str(params.get("lesson_id") or params.get("lesson") or "").strip()
|
single = str(params.get("name") or params.get("path") or params.get("url") or "")
|
if not lesson and single and not single.lower().endswith(".mp3"):
|
lesson = single
|
if lesson.lower().endswith(".mp3"):
|
lesson = ""
|
if not lesson:
|
conn.shadowing_clips = []
|
conn.shadowing_prompt = ""
|
return False
|
names = await _list_sd_mp3_names(conn)
|
clips = lesson_mp3_clips(
|
names,
|
getattr(conn, "active_app_id", "english_learning"),
|
getattr(conn, "current_task_id", "shadowing"),
|
lesson,
|
)
|
clips = [name for name in clips if not _is_full_lesson_mp3(name)]
|
if not clips:
|
conn.shadowing_clips = []
|
conn.shadowing_prompt = ""
|
return False
|
conn.shadowing_clips = clips
|
conn.shadowing_index = 0
|
conn.shadowing_prompt = clips[0]
|
conn.shadowing_awaiting_user = False
|
return True
|
|
|
async def _start_shadowing_sd(conn: "ConnectionHandler", params: Optional[Dict[str, Any]]) -> bool:
|
if not await _prepare_shadowing_sd(conn, params):
|
return False
|
play = _begin_shadowing_playlist(conn, params)
|
names = _play_lesson_names(play)
|
if not await _ensure_lesson_mp3s(conn, names, getattr(conn, "current_task_id", "shadowing") or "shadowing", params):
|
return False
|
conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
|
notice = await _execute_named_tool_play(conn, play)
|
if notice and notice.action in (Action.ERROR, Action.NOTFOUND):
|
return False
|
return True
|
|
|
async def _fill_repeater_play_name(
|
conn: "ConnectionHandler", params: Optional[Dict[str, Any]]
|
) -> Dict[str, Any]:
|
play = dict(params or {})
|
if play.get("items"):
|
return play
|
if str(play.get("name") or "").strip():
|
return play
|
stub = default_repeater_playlist(_task_for(conn))
|
if stub:
|
first = stub[0]
|
play["name"] = first.get("name") or ""
|
if play.get("repeat") is None:
|
play["repeat"] = first.get("repeat") or 1
|
if play.get("speed") is None and first.get("speed") is not None:
|
play["speed"] = first.get("speed")
|
conn.repeater_play_name = play["name"]
|
return play
|
|
|
async def maybe_flush_pending_play_after_tts(conn: "ConnectionHandler") -> None:
|
"""Start queued lesson audio only after enter-prompt TTS has finished on device."""
|
_ensure_runtime_fields(conn)
|
pending = getattr(conn, "pending_play_after_tts", None)
|
if not pending:
|
return
|
conn.pending_play_after_tts = None
|
kind = pending.get("kind") if isinstance(pending, dict) else None
|
try:
|
if kind == "repeater":
|
play_params = await _fill_repeater_play_name(conn, pending.get("params") or {})
|
await _execute_named_tool_play(conn, play_params)
|
elif kind == "shadowing":
|
play_params = pending.get("params") if isinstance(pending, dict) else None
|
if not play_params:
|
play_params = _begin_shadowing_playlist(conn, {})
|
conn.shadowing_awaiting_user = False
|
conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
|
await _execute_named_tool_play(conn, play_params)
|
except Exception as exc:
|
logger.bind(tag=TAG).warning(f"延后播放课文失败: {exc}")
|
|
|
def _lesson_start_phrase(task_id: str) -> str:
|
if task_id == "repeater":
|
return REPEATER_START_PHRASE
|
if task_id == "shadowing":
|
return SHADOWING_START_PHRASE
|
return ""
|
|
|
def _schedule_talk_start_phrase(task_id: str) -> str:
|
if task_id == "conversation":
|
return CHAT_START_PHRASE
|
if task_id == "live_translate":
|
return TRANSLATION_START_PHRASE
|
if task_id == "bilingual_chat":
|
return BILINGUAL_START_PHRASE
|
return ""
|
|
|
def _speak_lesson_start(conn: "ConnectionHandler", task_id: str) -> None:
|
phrase = _lesson_start_phrase(task_id)
|
if not phrase:
|
return
|
conn.sentence_id = uuid.uuid4().hex
|
_speak(conn, phrase)
|
|
|
def _lesson_end_speech(conn: "ConnectionHandler", phrase: str) -> str:
|
"""Short lesson-end cue plus the same default chat greeting used after 翻译/中英对话."""
|
greeting = (_greeting_text(conn) or "").strip()
|
phrase = (phrase or "").strip()
|
if phrase and greeting:
|
if phrase.endswith("。") or phrase.endswith("."):
|
return f"{phrase}{greeting}"
|
return f"{phrase}。{greeting}"
|
return phrase or greeting
|
|
|
async def _return_to_chat_after_lesson(
|
conn: "ConnectionHandler",
|
phrase: str,
|
reason: str = "play_completed",
|
) -> None:
|
"""Playlist already finished on device: speak the end cue now, then chat. Do not MCP-stop."""
|
conn.repeater_speed_queue = []
|
conn.repeater_playlist = []
|
end_session(conn, reason)
|
if getattr(conn, "tts", None) is not None:
|
await wait_for_tts_channels(conn, timeout=3)
|
conn.sentence_id = uuid.uuid4().hex
|
_speak(conn, _lesson_end_speech(conn, phrase))
|
await start_app_task(
|
conn,
|
"chat",
|
"conversation",
|
{},
|
source="voice",
|
execute_skills=False,
|
speak_switch=False,
|
stop_skills=False,
|
)
|
|
|
async def _shadowing_play_next_or_finish(conn: "ConnectionHandler") -> None:
|
"""Legacy helper: device now sequences clips. Only used if a caller still asks to finish."""
|
conn.shadowing_index = 0
|
conn.shadowing_prompt = ""
|
conn.shadowing_clips = []
|
await _return_to_chat_after_lesson(conn, SHADOWING_END_PHRASE, "finished")
|
|
|
def _mark_repeater_day(conn: "ConnectionHandler") -> None:
|
try:
|
mark_lesson_played_today(
|
str(getattr(conn, "device_id", "") or ""),
|
getattr(conn, "active_app_id", "") or "",
|
getattr(conn, "current_task_id", "") or "",
|
getattr(conn, "repeater_play_name", "") or "",
|
)
|
except Exception as exc:
|
logger.bind(tag=TAG).warning(f"记录泛听天数失败: {exc}")
|
|
|
def _apply_playback_speed(
|
conn: "ConnectionHandler",
|
task: Optional[Dict[str, Any]],
|
params: Optional[Dict[str, Any]],
|
) -> Dict[str, Any]:
|
"""Attach clamped speed. Multi-speed repeats are Server-driven (one pass per play)."""
|
play = dict(params or {})
|
try:
|
repeat_n = int(play.get("repeat") or 1)
|
except (TypeError, ValueError):
|
repeat_n = 1
|
if repeat_n < 1:
|
repeat_n = 1
|
device_id = str(getattr(conn, "device_id", "") or "")
|
app_id = getattr(conn, "active_app_id", "") or ""
|
task_id = getattr(conn, "current_task_id", "") or ""
|
lesson = str(play.get("name") or "")
|
day_index = lesson_day_index(device_id, app_id, task_id, lesson)
|
if play.get("speed") is not None:
|
speeds = [clamp_play_speed(play.get("speed"))] * repeat_n
|
else:
|
speeds = speeds_for_repeats(task, repeat_n, day_index)
|
conn.repeater_play_name = lesson
|
conn.repeater_power_off_after = bool(play.get("power_off_after") or False)
|
varied = len(speeds) > 1 and any(abs(item - speeds[0]) > 0.001 for item in speeds[1:])
|
if varied:
|
conn.repeater_speed_queue = speeds[1:]
|
play["speed"] = speeds[0]
|
play["repeat"] = 1
|
play["power_off_after"] = False
|
else:
|
conn.repeater_speed_queue = []
|
play["speed"] = speeds[0]
|
play["repeat"] = repeat_n
|
# 泛听清单结束由 Server 收 audio.completed 后切回对话。设备不要 finish_task 写 NVS。
|
play["finish_task"] = False
|
return play
|
|
|
def _begin_repeater_playlist(
|
conn: "ConnectionHandler",
|
task: Optional[Dict[str, Any]],
|
params: Optional[Dict[str, Any]],
|
) -> Dict[str, Any]:
|
"""One shot: send the whole playlist. Repeat is just the same clip again, no mode switch."""
|
items = playlist_from_params(task, params, "repeater", default_repeater_playlist)
|
normalized = []
|
for raw in items:
|
if not isinstance(raw, dict):
|
continue
|
name = str(raw.get("name") or "")
|
if not name:
|
continue
|
try:
|
repeat_n = int(raw.get("repeat") or 1)
|
except (TypeError, ValueError):
|
repeat_n = 1
|
if repeat_n < 1:
|
repeat_n = 1
|
item = {
|
"name": name,
|
"repeat": repeat_n,
|
"speed": clamp_play_speed(
|
raw["speed"] if raw.get("speed") is not None else 1.0
|
),
|
"power_off_after": bool(raw.get("power_off_after") or False),
|
"finish_task": False,
|
}
|
normalized.append(item)
|
conn.repeater_playlist = []
|
conn.repeater_speed_queue = []
|
first = normalized[0] if normalized else dict(params or {})
|
conn.repeater_play_name = str(first.get("name") or "")
|
conn.repeater_power_off_after = bool(first.get("power_off_after") or False)
|
play = dict(first)
|
play["items"] = normalized
|
play["finish_task"] = False
|
return play
|
|
|
async def _continue_repeater_if_needed(conn: "ConnectionHandler") -> bool:
|
queue = list(getattr(conn, "repeater_speed_queue", None) or [])
|
if queue:
|
nxt = queue.pop(0)
|
conn.repeater_speed_queue = queue
|
last_speed = len(queue) == 0 and not list(getattr(conn, "repeater_playlist", None) or [])
|
try:
|
play_params = await _fill_repeater_play_name(
|
conn,
|
{
|
"name": getattr(conn, "repeater_play_name", "") or "",
|
"speed": nxt,
|
"repeat": 1,
|
"power_off_after": last_speed
|
and bool(getattr(conn, "repeater_power_off_after", False)),
|
"finish_task": False,
|
},
|
)
|
await _execute_named_tool_play(conn, play_params)
|
except Exception as exc:
|
logger.bind(tag=TAG).warning(f"泛听下一遍失败: {exc}")
|
conn.repeater_speed_queue = []
|
return False
|
return True
|
|
rest = list(getattr(conn, "repeater_playlist", None) or [])
|
if not rest:
|
return False
|
nxt, conn.repeater_playlist = rest[0], rest[1:]
|
play_params = _apply_playback_speed(conn, _task_for(conn), nxt)
|
play_params["finish_task"] = False
|
try:
|
play_params = await _fill_repeater_play_name(conn, play_params)
|
await _execute_named_tool_play(conn, play_params)
|
except Exception as exc:
|
logger.bind(tag=TAG).warning(f"泛听下一首失败: {exc}")
|
conn.repeater_playlist = []
|
return False
|
return True
|
|
|
async def _execute_named_tool_play(conn, params):
|
items = params.get("items") if isinstance(params.get("items"), list) else []
|
first = items[0] if items else params
|
play_args = {
|
"name": (first.get("name") if isinstance(first, dict) else None) or params.get("name") or "",
|
"repeat": int((first.get("repeat") if isinstance(first, dict) else None) or params.get("repeat") or 1),
|
"power_off_after": bool(params.get("power_off_after") or False),
|
"speed": clamp_play_speed(
|
(first.get("speed") if isinstance(first, dict) and first.get("speed") is not None else None)
|
if items
|
else (params["speed"] if params.get("speed") is not None else 1.0)
|
),
|
"finish_task": bool(params.get("finish_task", True)),
|
"app_id": str(getattr(conn, "active_app_id", "") or ""),
|
"task_id": str(getattr(conn, "current_task_id", "") or ""),
|
"force": True,
|
}
|
if items:
|
play_args["items"] = json.dumps(items, ensure_ascii=False)
|
play_args["speed"] = clamp_play_speed(
|
first["speed"] if first.get("speed") is not None else 1.0
|
)
|
play_args["power_off_after"] = bool(
|
items[-1].get("power_off_after") if items else False
|
)
|
if params.get("listen_ratio") is not None:
|
try:
|
play_args["listen_ratio"] = float(params["listen_ratio"])
|
except (TypeError, ValueError):
|
pass
|
if play_args["task_id"] in ("repeater", "shadowing"):
|
play_args["finish_task"] = False
|
result = await _execute_named_tool(conn, DEVICE_PLAY_TOOL, play_args)
|
blob = ""
|
if result:
|
blob = str(result.response or result.result or "")
|
compact = blob.replace(" ", "")
|
if '"duplicate_ignored":true' in compact:
|
logger.bind(tag=TAG).warning("设备忽略了同一课文的下一遍,强制再播一次")
|
play_args["speed"] = clamp_play_speed(float(play_args["speed"]) + 0.02)
|
play_args["force"] = True
|
result = await _execute_named_tool(conn, DEVICE_PLAY_TOOL, play_args)
|
blob = ""
|
if result:
|
blob = str(result.response or result.result or "")
|
compact = blob.replace(" ", "")
|
rejected = (result and result.action in (Action.ERROR, Action.NOTFOUND)) or (
|
"no_mp3" in blob
|
or '"accepted":false' in compact
|
or '"duplicate_ignored":true' in compact
|
)
|
if rejected:
|
if not play_args["name"]:
|
return ActionResponse(
|
action=Action.RESPONSE,
|
response="泛听已打开,但 SD 卡上还没有可播放的课文。",
|
)
|
return ActionResponse(
|
action=Action.RESPONSE,
|
response=(result.response if result else None) or "这台设备没有本地泛听播放能力",
|
)
|
return None
|
|
|
def _apply_last_lesson_params(
|
conn: "ConnectionHandler",
|
task_id: str,
|
params: Optional[Dict[str, Any]],
|
source: str,
|
) -> Dict[str, Any]:
|
params = dict(params or {})
|
if task_id not in ("repeater", "shadowing"):
|
return params
|
device_id = str(getattr(conn, "device_id", "") or "")
|
has_lesson = str(params.get("lesson_id") or params.get("lesson") or "").strip()
|
if has_lesson:
|
remember_last_lesson_params(device_id, task_id, params)
|
return params
|
saved = load_last_lesson_params(device_id, task_id)
|
if saved:
|
merged = dict(saved)
|
for key, value in params.items():
|
if value not in (None, "", []):
|
merged[key] = value
|
return merged
|
return params
|
|
|
def _lesson_missing_speech(task_id: str, params: Optional[Dict[str, Any]], fallback: str) -> str:
|
kind = "泛听" if task_id == "repeater" else "跟读"
|
lesson = str((params or {}).get("lesson_id") or (params or {}).get("lesson") or "").strip()
|
if not lesson:
|
return f"还没有下发过{kind}课文,请先在家长端选一课。"
|
return fallback
|
|
|
async def start_app_task(
|
conn: "ConnectionHandler",
|
app_id: str,
|
task_id: str = "",
|
params: Optional[Dict[str, Any]] = None,
|
source: str = "voice",
|
execute_skills: bool = True,
|
speak_switch: bool = True,
|
stop_skills: bool = True,
|
) -> ActionResponse:
|
"""Single entry for MQTT / schedule / voice / default chat."""
|
_ensure_runtime_fields(conn)
|
if await prompt_bind_if_needed(conn):
|
return ActionResponse(action=Action.NONE, response="设备未绑定")
|
conn.client_abort = False
|
tts = getattr(conn, "tts", None)
|
if tts is not None:
|
tts.tts_stop_request = False
|
params = params or {}
|
if params.get("announce") is False:
|
speak_switch = False
|
app, task = get_task(app_id, task_id)
|
if not app:
|
return ActionResponse(action=Action.ERROR, response=f"未知的 App: {app_id}")
|
if not task:
|
default_task = app.get("default_task") or ""
|
app, task = get_task(app_id, default_task)
|
if not task:
|
return ActionResponse(action=Action.ERROR, response=f"未知的 Task: {app_id}/{task_id}")
|
|
resolved_task_id = task_id or app.get("default_task") or "conversation"
|
params = _apply_last_lesson_params(conn, resolved_task_id, params, source)
|
new_p = source_priority(source)
|
cur_p = source_priority(getattr(conn, "current_source", "default_chat"))
|
same_task = (
|
getattr(conn, "active_app_id", "") == app_id
|
and getattr(conn, "current_task_id", "") == resolved_task_id
|
)
|
# 口令回对话要能退出家长即时/计划任务。其它低优先级切换仍然拦住。
|
voice_exit_to_chat = (
|
source == "voice" and app_id == "chat" and resolved_task_id == "conversation"
|
)
|
if new_p < cur_p and not same_task and not voice_exit_to_chat:
|
logger.bind(tag=TAG).info(
|
f"拒绝低优先级任务 {source}({new_p}) < {conn.current_source}({cur_p})"
|
)
|
return ActionResponse(
|
action=Action.NONE,
|
response="当前有更高优先级的任务,先完成后再切换",
|
)
|
|
if source in ("voice", "mqtt_immediate", "schedule"):
|
schedule_close_parent_except(conn, source, app_id, resolved_task_id)
|
|
prev_app_id = getattr(conn, "active_app_id", "chat")
|
prev_task_id = getattr(conn, "current_task_id", "conversation")
|
prev_app, prev_task = get_task(prev_app_id, prev_task_id)
|
switching = (prev_app_id != app_id) or (prev_task_id != resolved_task_id)
|
|
if not same_task:
|
if stop_skills:
|
await _stop_current_skills(conn)
|
if app_id != "chat":
|
conn.chat_greeted = False
|
|
conn.active_app_id = app_id
|
conn.current_task_id = resolved_task_id
|
conn.interaction_mode = task.get("interaction_mode") or app.get("mode") or "CHAT"
|
conn.current_source = source
|
conn.tool_policy = app.get("tool_policy") or "inherit_agent"
|
conn.tool_allowlist = list(app.get("tools") or [])
|
_apply_prompt(conn, app, params)
|
_refresh_tools(conn)
|
|
logger.bind(tag=TAG).info(
|
f"start_app_task app={app_id} task={resolved_task_id} mode={conn.interaction_mode} source={source}"
|
)
|
|
device_ready = bool(params.get("sd_ready"))
|
if device_ready:
|
speak_switch = False
|
logger.bind(tag=TAG).info("课文已在 SD,先播开始语,再由设备本地开播")
|
_speak_lesson_start(conn, resolved_task_id)
|
|
if app_id == "chat":
|
end_session(conn, "user_exit")
|
# 家长下发的对话要记一条执行记录;开机欢迎词 / 口令回对话不要另开 session。
|
if source in ("mqtt_immediate", "schedule"):
|
begin_session(conn, app_id, resolved_task_id, source, conn.interaction_mode)
|
elif switching:
|
begin_session(conn, app_id, resolved_task_id, source, conn.interaction_mode)
|
|
pending_play = None
|
lesson_missing = False
|
if execute_skills and not device_ready:
|
if app_sd_layout_paths(app_id, app):
|
await ensure_app_sd_dirs(conn, app_id)
|
for skill in task.get("skills") or []:
|
skill_name = (skill or {}).get("name") or ""
|
if skill_name in ("self.mp3.play", DEVICE_PLAY_TOOL):
|
play_params = _begin_repeater_playlist(conn, task, params)
|
items = play_params.get("items") if isinstance(play_params, dict) else None
|
if not items and not (isinstance(play_params, dict) and play_params.get("name")):
|
lesson_missing = True
|
else:
|
names = _play_lesson_names(play_params)
|
if not await _ensure_lesson_mp3s(conn, names, resolved_task_id, params):
|
lesson_missing = True
|
else:
|
pending_play = {"kind": "repeater", "params": play_params}
|
elif skill_name == "self.power_off":
|
await _execute_named_tool(conn, "self_power_off", {})
|
|
if source == "schedule":
|
talk_phrase = _schedule_talk_start_phrase(resolved_task_id)
|
if talk_phrase:
|
conn.sentence_id = uuid.uuid4().hex
|
_speak(conn, talk_phrase)
|
speak_switch = False
|
if resolved_task_id == "conversation":
|
conn.chat_greeted = True
|
|
announce = ""
|
boot_chat = app_id == "chat" and source == "default_chat"
|
if boot_chat:
|
# 开机进 chat:必须播欢迎词。不要被 same_task / just_woken_up 跳过。
|
enable = (conn.config or {}).get("enable_proactive_greeting", True)
|
if enable and not getattr(conn, "chat_greeted", False):
|
if not await wait_for_tts_channels(conn, timeout=10):
|
logger.bind(tag=TAG).error("开机欢迎词跳过:TTS通道未就绪")
|
else:
|
conn.chat_greeted = True
|
conn.sentence_id = uuid.uuid4().hex
|
_speak(conn, _greeting_text(conn))
|
elif switching:
|
announce = _switch_announcement(prev_app, prev_task, app, task, app_id)
|
if lesson_missing:
|
prefix = announce or "现在已经切换至泛听模式。"
|
announce = _lesson_missing_speech(
|
resolved_task_id,
|
params,
|
f"{prefix}这课还没有放到SD卡。",
|
)
|
if getattr(conn, "language_pair_fallback", False) and conn.interaction_mode in (
|
"BILINGUAL",
|
"TRANSLATION",
|
):
|
announce = f"{announce or ''}这套语言先用中英来练。"
|
if conn.interaction_mode == "SHADOWING" and not device_ready:
|
play_params = _begin_shadowing_playlist(conn, params)
|
names = _play_lesson_names(play_params)
|
if not names:
|
if await _prepare_shadowing_sd(conn, params):
|
play_params = _begin_shadowing_playlist(conn, params)
|
names = _play_lesson_names(play_params)
|
if not names:
|
prefix = announce or "现在已经切换至跟读模式。"
|
announce = _lesson_missing_speech(
|
resolved_task_id,
|
params,
|
f"{prefix}跟读课文还没有放到SD卡。",
|
)
|
elif not await _ensure_lesson_mp3s(conn, names, resolved_task_id, params):
|
prefix = announce or "现在已经切换至跟读模式。"
|
announce = _lesson_missing_speech(
|
resolved_task_id,
|
params,
|
f"{prefix}跟读课文还没有放到SD卡。",
|
)
|
else:
|
announce = _lesson_start_phrase(resolved_task_id) or announce or "开始跟读任务"
|
pending_play = {
|
"kind": "shadowing",
|
"params": play_params,
|
}
|
start_phrase = _lesson_start_phrase(resolved_task_id)
|
if start_phrase and pending_play and not lesson_missing:
|
announce = start_phrase
|
if speak_switch and announce:
|
conn.sentence_id = uuid.uuid4().hex
|
conn.pending_play_after_tts = pending_play
|
_speak(conn, announce)
|
elif pending_play:
|
conn.pending_play_after_tts = pending_play
|
await maybe_flush_pending_play_after_tts(conn)
|
if app_id == "chat":
|
conn.chat_greeted = True
|
elif conn.interaction_mode == "CHAT":
|
enable = (conn.config or {}).get("enable_proactive_greeting", True)
|
woken = bool(getattr(conn, "just_woken_up", False))
|
if enable and not woken and not getattr(conn, "chat_greeted", False):
|
conn.chat_greeted = True
|
conn.sentence_id = uuid.uuid4().hex
|
_speak(conn, _greeting_text(conn))
|
elif conn.interaction_mode == "SHADOWING" and not device_ready and not getattr(conn, "shadowing_clips", None):
|
started = await _start_shadowing_sd(conn, params)
|
if not started and speak_switch:
|
conn.sentence_id = uuid.uuid4().hex
|
_speak(conn, _lesson_missing_speech(resolved_task_id, params, "跟读课文还没有放到SD卡。"))
|
elif pending_play:
|
conn.pending_play_after_tts = pending_play
|
await maybe_flush_pending_play_after_tts(conn)
|
|
app_name = _display_name(app, task)
|
return ActionResponse(
|
action=Action.NONE,
|
result=f"{app_id}/{resolved_task_id}",
|
response=announce or f"已打开{app_name}",
|
)
|
|
|
async def switch_app(
|
conn: "ConnectionHandler",
|
app_id: str,
|
source: str = "voice",
|
task_id: str = "",
|
) -> ActionResponse:
|
app = get_app(app_id)
|
if not app:
|
return ActionResponse(action=Action.ERROR, response=f"不支持的 App: {app_id}")
|
resolved = task_id or app.get("default_task") or ""
|
return await start_app_task(conn, app_id, resolved, {}, source)
|
|
|
async def on_user_wakeup(conn: "ConnectionHandler") -> None:
|
"""User wake word returns to chat; do not treat [app_task] as wakeup."""
|
_ensure_runtime_fields(conn)
|
if getattr(conn, "interaction_mode", "CHAT") == "CHAT" and getattr(conn, "active_app_id", "chat") == "chat":
|
return
|
await start_app_task(conn, "chat", "conversation", {}, source="voice")
|
|
|
def match_voice_switch(text: str) -> Optional[tuple]:
|
"""Map a short phrase to (app_id, task_id) using apps.json voice_enter / voice_exit."""
|
if not text:
|
return None
|
_, filtered = _strip(text)
|
return match_registry_voice(filtered)
|
|
|
def _strip(text: str) -> tuple:
|
from core.utils.util import remove_punctuation_and_length
|
|
return remove_punctuation_and_length(text)
|
|
|
def is_exit_to_chat(text: str) -> bool:
|
mapped = match_voice_switch(text)
|
return bool(mapped and mapped[0] == "chat")
|
|
|
def should_skip_listen_stop_asr(conn: Any) -> bool:
|
"""True when listen stop is closing the mic for lesson playback, not a follow-read."""
|
mode = getattr(conn, "interaction_mode", "CHAT")
|
if mode == "PLAY_AUDIO":
|
return True
|
if mode == "SHADOWING" and not getattr(conn, "shadowing_awaiting_user", False):
|
return True
|
return False
|
|
|
def is_exit_translation(text: str) -> bool:
|
return is_exit_to_chat(text)
|
|
|
def _plain_user_text(text: str) -> str:
|
"""ASR 可能把原文包在 JSON 里,翻译只要 content。"""
|
if not text:
|
return ""
|
raw = str(text).strip()
|
if raw.startswith("{") and raw.endswith("}"):
|
try:
|
import json
|
|
data = json.loads(raw)
|
if isinstance(data, dict) and data.get("content"):
|
return str(data["content"]).strip()
|
except Exception:
|
pass
|
return raw
|
|
|
def _norm_for_echo(text: str) -> str:
|
_, filtered = _strip(text or "")
|
return re.sub(r"\s+", "", (filtered or "").lower())
|
|
|
def is_echo_of_prompt(user_text: str, prompt: str) -> bool:
|
a = _norm_for_echo(user_text)
|
b = _norm_for_echo(prompt)
|
if not a or not b:
|
return False
|
if a == b or a in b or b in a:
|
return True
|
return difflib.SequenceMatcher(None, a, b).ratio() >= 0.82
|
|
|
def detect_user_language(text: str, hint: Optional[str] = None) -> str:
|
if hint:
|
h = str(hint).lower()
|
if h.startswith("zh") or h in ("cn", "yue", "chinese"):
|
return "zh"
|
if h.startswith("en") or h in ("english",):
|
return "en"
|
body = _plain_user_text(text)
|
cjk = len(re.findall(r"[\u4e00-\u9fff]", body))
|
latin = len(re.findall(r"[A-Za-z]", body))
|
return "zh" if cjk >= max(latin, 1) else "en"
|
|
|
def wrap_bilingual_user_text(text: str, user_lang: str) -> str:
|
if user_lang == "zh":
|
return "Respond in English only. Do not use Chinese characters.\n\n" + text
|
return "请只用中文回答,不要出现英文单词或句子。\n\n" + text
|
|
|
def _looks_like_tool_error(text: str) -> bool:
|
if not text:
|
return True
|
s = str(text)
|
if "Error executing tool" in s or "isError=True" in s or "isError=true" in s:
|
return True
|
if "未配置" in s and "api_key" in s:
|
return True
|
return False
|
|
|
def _record_shadowing_turn(
|
conn: "ConnectionHandler",
|
text: str,
|
echo_rejected: bool = False,
|
index: Optional[int] = None,
|
) -> None:
|
session = current_session(conn)
|
if not session:
|
return
|
if index is None:
|
index = int(getattr(conn, "shadowing_assess_index", getattr(conn, "shadowing_index", 0)) or 0)
|
session.upsert_turn(
|
index=index,
|
prompt_text=getattr(conn, "shadowing_prompt", "") or "",
|
asr_text=_plain_user_text(text),
|
echo_rejected=echo_rejected,
|
expected_text=_expected_for_index(conn, index),
|
)
|
session.save()
|
|
|
def apply_shadowing_user_text(conn: "ConnectionHandler", text: str) -> str:
|
"""Apply one ASR utterance. Returns exit | ignore | echo | recorded.
|
|
ASR never advances the clip index; the listen-window clock does.
|
Late ASR after the window still scores the sentence that was just listened to.
|
Device plays the next clip itself; Server does not MCP the next sentence.
|
"""
|
if is_exit_to_chat(text):
|
end_session(conn, "user_exit")
|
return "exit"
|
if getattr(conn, "client_is_speaking", False) and not getattr(
|
conn, "shadowing_awaiting_user", False
|
):
|
logger.bind(tag=TAG).info("跟读:播放中忽略拾音,避免喇叭回声触发对话")
|
return "ignore"
|
prompt = getattr(conn, "shadowing_prompt", "") or ""
|
if (
|
getattr(conn, "shadowing_awaiting_user", False)
|
and prompt
|
and not _is_clip_path(prompt)
|
and is_echo_of_prompt(text, prompt)
|
):
|
logger.bind(tag=TAG).info("跟读:忽略与范读过于相似的识别结果")
|
session = current_session(conn)
|
if session:
|
session.add_event("echo_rejected")
|
session.save()
|
return "echo"
|
_record_shadowing_turn(conn, text, echo_rejected=False)
|
return "recorded"
|
|
|
async def handle_shadowing_turn(conn: "ConnectionHandler", text: str) -> None:
|
"""Score this utterance off the chat path. Next clip is sequenced on device."""
|
outcome = apply_shadowing_user_text(conn, text)
|
if outcome == "exit":
|
_cancel_shadowing_timeout(conn)
|
await start_app_task(conn, "chat", "conversation", {}, source="voice")
|
return
|
if outcome in ("ignore", "echo"):
|
return
|
|
step_index = int(
|
getattr(conn, "shadowing_assess_index", getattr(conn, "shadowing_index", 0)) or 0
|
)
|
logger.bind(tag=TAG).info(f"跟读 ASR 仅评估不上对话 step={step_index}")
|
_schedule_deferred_assessment(conn, step_index)
|
|
|
async def handle_skill_event(conn: "ConnectionHandler", text: str) -> bool:
|
"""Handle `[skill_event]audio.completed|app|task|duration_ms`. Returns True if consumed."""
|
parsed = parse_skill_event(text)
|
if parsed is None:
|
return False
|
_ensure_runtime_fields(conn)
|
name = parsed.get("name") or ""
|
app_id = parsed.get("app_id") or ""
|
task_id = parsed.get("task_id") or ""
|
try:
|
duration_ms = int(parsed.get("duration_ms") or 0)
|
except (TypeError, ValueError):
|
duration_ms = 0
|
logger.bind(tag=TAG).info(f"skill_event {name} {app_id}/{task_id} duration_ms={duration_ms}")
|
|
mode = getattr(conn, "interaction_mode", "CHAT")
|
# 听窗时钟必须先于 session.save():磁盘/HTTP 不能挡住 1.2×duration 的热路径。
|
shadowing_playlist_done = name in ("audio.playlist_completed", "playlist.completed") and (
|
mode == "SHADOWING" or task_id == "shadowing"
|
)
|
if shadowing_playlist_done:
|
_cancel_shadowing_timeout(conn)
|
if getattr(conn, "shadowing_awaiting_user", False):
|
await _on_shadowing_listen_window_closed(conn)
|
else:
|
save_shadowing_listen_audio(conn)
|
session = getattr(conn, "task_session", None)
|
if session:
|
session.add_event(name, app_id, task_id)
|
await _return_to_chat_after_lesson(conn, SHADOWING_END_PHRASE, "finished")
|
return True
|
if mode == "SHADOWING" and name == "audio.completed":
|
if getattr(conn, "shadowing_awaiting_user", False):
|
await _on_shadowing_listen_window_closed(conn)
|
conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
|
_schedule_shadowing_listen_timeout(conn, duration_ms)
|
session = getattr(conn, "task_session", None)
|
if session:
|
session.add_event(name, app_id, task_id)
|
return True
|
if mode == "SHADOWING" and name == "audio.stopped":
|
session = getattr(conn, "task_session", None)
|
if session:
|
session.add_event(name, app_id, task_id)
|
end_session(conn, "play_stopped")
|
await start_app_task(
|
conn,
|
"chat",
|
"conversation",
|
{},
|
source="voice",
|
execute_skills=False,
|
speak_switch=False,
|
)
|
return True
|
|
repeater_done = name == "audio.completed" and (
|
mode == "PLAY_AUDIO" or task_id == "repeater"
|
)
|
if repeater_done:
|
continued = await _continue_repeater_if_needed(conn)
|
session = getattr(conn, "task_session", None)
|
if session:
|
session.add_event(name, app_id, task_id)
|
if continued:
|
if session:
|
session.save()
|
return True
|
_mark_repeater_day(conn)
|
await _return_to_chat_after_lesson(conn, REPEATER_END_PHRASE, "play_completed")
|
return True
|
if mode == "PLAY_AUDIO" and name == "audio.stopped":
|
conn.repeater_speed_queue = []
|
|
session = getattr(conn, "task_session", None)
|
if session:
|
session.add_event(name, app_id, task_id)
|
session.save()
|
|
if name == "audio.completed":
|
end_session(conn, "play_completed")
|
await start_app_task(
|
conn,
|
"chat",
|
"conversation",
|
{},
|
source="voice",
|
execute_skills=False,
|
speak_switch=True,
|
)
|
elif name == "audio.stopped":
|
end_session(conn, "play_stopped")
|
if getattr(conn, "active_app_id", "chat") != "chat":
|
await start_app_task(
|
conn,
|
"chat",
|
"conversation",
|
{},
|
source="voice",
|
execute_skills=False,
|
speak_switch=False,
|
)
|
else:
|
logger.bind(tag=TAG).warning(f"未知 skill_event: {name}")
|
return True
|
|
|
async def run_translation(conn: "ConnectionHandler", text: str) -> str:
|
"""Translate with the connection LLM first (智控台已配的模型). MCP is fallback only."""
|
text = _plain_user_text(text)
|
if not text:
|
return ""
|
|
from plugins_func.register import all_function_registry
|
|
item = all_function_registry.get("translate")
|
if item:
|
try:
|
maybe = item.func(conn, text=text)
|
if hasattr(maybe, "__await__"):
|
maybe = await maybe
|
if isinstance(maybe, ActionResponse) and maybe.action not in (
|
Action.ERROR,
|
Action.NOTFOUND,
|
):
|
out = (maybe.response or maybe.result or "").strip()
|
if out and not _looks_like_tool_error(out):
|
return out
|
except Exception as e:
|
logger.bind(tag=TAG).warning(f"翻译插件失败: {e}")
|
|
handler = getattr(conn, "func_handler", None)
|
if handler and handler.has_tool("translate"):
|
result = await _execute_named_tool(conn, "translate", {"text": text})
|
if result and result.action not in (Action.ERROR, Action.NOTFOUND):
|
out = _extract_translate_text(result.response or result.result or "")
|
if out and not _looks_like_tool_error(out):
|
return out
|
|
llm = getattr(conn, "llm", None)
|
if llm and hasattr(llm, "response_no_stream"):
|
try:
|
return (
|
llm.response_no_stream(TRANSLATOR_PROMPT, text, temperature=0.1) or ""
|
).strip()
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"翻译 LLM 失败: {e}")
|
return ""
|
|
|
def _extract_translate_text(raw: Any) -> str:
|
if raw is None:
|
return ""
|
if isinstance(raw, str):
|
text = raw.strip()
|
if text.startswith("{") and "text" in text:
|
try:
|
import json
|
|
data = json.loads(text)
|
if isinstance(data, dict):
|
return str(data.get("text") or data.get("content") or text)
|
except Exception:
|
pass
|
return text
|
if hasattr(raw, "content"):
|
content = raw.content
|
if isinstance(content, list) and content:
|
first = content[0]
|
return getattr(first, "text", None) or str(first)
|
return str(content)
|
return str(raw).strip()
|