"""Phase 2 TaskSession: in-memory + JSON file. Not a TaskInstance."""
|
|
from __future__ import annotations
|
|
import asyncio
|
import json
|
import os
|
import time
|
import uuid
|
from dataclasses import asdict, dataclass, field
|
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
|
from config.config_loader import get_project_dir
|
from config.logger import setup_logging
|
|
if TYPE_CHECKING:
|
from core.connection import ConnectionHandler
|
|
TAG = __name__
|
logger = setup_logging()
|
|
SKILL_EVENT_PREFIX = "[skill_event]"
|
DEFER_ASR_REPORT_MODES = ("PLAY_AUDIO", "SHADOWING")
|
|
|
def _now_ms() -> int:
|
return int(time.time() * 1000)
|
|
|
def session_dir() -> str:
|
return os.path.join(get_project_dir(), "data", "task_sessions")
|
|
|
def parse_skill_event(text: str) -> Optional[Dict[str, str]]:
|
"""Parse `[skill_event]audio.completed|app_id|task_id` into a dict."""
|
if not text:
|
return None
|
raw = str(text).strip()
|
if not raw.startswith(SKILL_EVENT_PREFIX):
|
return None
|
payload = raw[len(SKILL_EVENT_PREFIX) :].strip()
|
parts = [p.strip() for p in payload.split("|")]
|
name = parts[0] if parts else ""
|
if not name:
|
return None
|
return {
|
"name": name,
|
"app_id": parts[1] if len(parts) > 1 else "",
|
"task_id": parts[2] if len(parts) > 2 else "",
|
"duration_ms": parts[3] if len(parts) > 3 else "",
|
}
|
|
|
def should_report_asr_now(conn: Any) -> bool:
|
"""PLAY_AUDIO / SHADOWING defer report so echo and ignored uplink are not archived."""
|
mode = getattr(conn, "interaction_mode", "CHAT")
|
return mode not in DEFER_ASR_REPORT_MODES
|
|
|
def stash_asr_audio(conn: Any, audio_snapshot: Any) -> None:
|
conn.last_asr_audio_snapshot = audio_snapshot
|
|
|
@dataclass
|
class TaskSession:
|
task_session_id: str
|
ws_session_id: str = ""
|
device_id: str = ""
|
agent_id: str = ""
|
app_id: str = ""
|
task_id: str = ""
|
source: str = "voice"
|
interaction_mode: str = ""
|
started_at: int = 0
|
ended_at: int = 0
|
status: str = "running"
|
end_reason: str = ""
|
turns: List[Dict[str, Any]] = field(default_factory=list)
|
events: List[Dict[str, Any]] = field(default_factory=list)
|
|
def find_turn(self, index: int) -> Optional[Dict[str, Any]]:
|
for turn in self.turns:
|
if turn.get("index") == index:
|
return turn
|
return None
|
|
def upsert_turn(
|
self,
|
index: int,
|
prompt_text: str = "",
|
asr_text: Optional[str] = None,
|
echo_rejected: bool = False,
|
expected_text: Optional[str] = None,
|
metrics: Optional[List[Dict[str, Any]]] = None,
|
assessed_at: Optional[int] = None,
|
audio_id: Optional[str] = None,
|
) -> Dict[str, Any]:
|
"""Same step_index stays one row; last ASR wins. Empty metrics do not wipe old."""
|
existing = self.find_turn(index)
|
if existing is None:
|
turn: Dict[str, Any] = {
|
"index": index,
|
"prompt_text": prompt_text or "",
|
"asr_text": asr_text or "",
|
"echo_rejected": bool(echo_rejected),
|
"recorded_at": _now_ms(),
|
}
|
if expected_text:
|
turn["expected_text"] = expected_text
|
if metrics:
|
turn["metrics"] = metrics
|
if assessed_at is not None:
|
turn["assessed_at"] = assessed_at
|
if audio_id:
|
turn["audio_id"] = audio_id
|
self.turns.append(turn)
|
return turn
|
if prompt_text:
|
existing["prompt_text"] = prompt_text
|
if asr_text is not None:
|
existing["asr_text"] = asr_text
|
existing["echo_rejected"] = bool(echo_rejected)
|
existing["recorded_at"] = _now_ms()
|
if expected_text:
|
existing["expected_text"] = expected_text
|
if metrics:
|
existing["metrics"] = metrics
|
if assessed_at is not None:
|
existing["assessed_at"] = assessed_at
|
if audio_id:
|
existing["audio_id"] = audio_id
|
return existing
|
|
def add_turn(
|
self,
|
index: int,
|
prompt_text: str,
|
asr_text: str,
|
echo_rejected: bool = False,
|
) -> None:
|
self.upsert_turn(
|
index=index,
|
prompt_text=prompt_text,
|
asr_text=asr_text,
|
echo_rejected=echo_rejected,
|
)
|
|
def add_event(self, name: str, app_id: str = "", task_id: str = "") -> None:
|
self.events.append(
|
{
|
"name": name,
|
"app_id": app_id or self.app_id,
|
"task_id": task_id or self.task_id,
|
"at": _now_ms(),
|
}
|
)
|
|
def finish(self, reason: str) -> None:
|
if self.status != "running":
|
return
|
self.status = "completed" if reason in ("finished", "play_completed") else "cancelled"
|
self.end_reason = reason
|
self.ended_at = _now_ms()
|
|
def to_dict(self) -> Dict[str, Any]:
|
return asdict(self)
|
|
def save(self) -> None:
|
try:
|
directory = session_dir()
|
os.makedirs(directory, exist_ok=True)
|
path = os.path.join(directory, f"{self.task_session_id}.json")
|
with open(path, "w", encoding="utf-8") as handle:
|
json.dump(self.to_dict(), handle, ensure_ascii=False, indent=2)
|
except Exception as exc:
|
logger.bind(tag=TAG).warning(f"保存 TaskSession 失败: {exc}")
|
return
|
if getattr(self, "_skip_upsert", False):
|
return
|
try:
|
_schedule_upsert(self.to_dict(), self)
|
except Exception as exc:
|
logger.bind(tag=TAG).warning(f"TaskInstance 上报调度失败: {exc}")
|
|
@classmethod
|
def load(cls, task_session_id: str) -> Optional["TaskSession"]:
|
path = os.path.join(session_dir(), f"{task_session_id}.json")
|
if not os.path.isfile(path):
|
return None
|
with open(path, "r", encoding="utf-8") as handle:
|
data = json.load(handle)
|
return cls(
|
task_session_id=data.get("task_session_id") or task_session_id,
|
ws_session_id=data.get("ws_session_id") or "",
|
device_id=data.get("device_id") or "",
|
agent_id=data.get("agent_id") or "",
|
app_id=data.get("app_id") or "",
|
task_id=data.get("task_id") or "",
|
source=data.get("source") or "voice",
|
interaction_mode=data.get("interaction_mode") or "",
|
started_at=int(data.get("started_at") or 0),
|
ended_at=int(data.get("ended_at") or 0),
|
status=data.get("status") or "running",
|
end_reason=data.get("end_reason") or "",
|
turns=list(data.get("turns") or []),
|
events=list(data.get("events") or []),
|
)
|
|
|
def begin_session(conn: "ConnectionHandler", app_id: str, task_id: str, source: str, mode: str) -> TaskSession:
|
existing = getattr(conn, "task_session", None)
|
if existing and getattr(existing, "status", "") == "running":
|
end_session(conn, "preempted")
|
session = TaskSession(
|
task_session_id=uuid.uuid4().hex,
|
ws_session_id=str(getattr(conn, "session_id", "") or ""),
|
device_id=str(getattr(conn, "device_id", "") or ""),
|
agent_id=str(getattr(conn, "agent_id", "") or ""),
|
app_id=app_id,
|
task_id=task_id,
|
source=source or "voice",
|
interaction_mode=mode or "",
|
started_at=_now_ms(),
|
status="running",
|
)
|
conn.task_session = session
|
session.save()
|
logger.bind(tag=TAG).info(
|
f"TaskSession 开始 {session.task_session_id} {app_id}/{task_id} mode={mode}"
|
)
|
return session
|
|
|
def end_session(conn: "ConnectionHandler", reason: str) -> None:
|
session = getattr(conn, "task_session", None)
|
if not session or getattr(session, "status", "") != "running":
|
return
|
session.finish(reason)
|
session.save()
|
logger.bind(tag=TAG).info(
|
f"TaskSession 结束 {session.task_session_id} status={session.status} reason={reason}"
|
)
|
_schedule_close_parent_matching(conn, session, reason)
|
|
|
def current_session(conn: "ConnectionHandler") -> Optional[TaskSession]:
|
session = getattr(conn, "task_session", None)
|
if session and getattr(session, "status", "") == "running":
|
return session
|
return None
|
|
|
def schedule_close_parent_except(
|
conn: "ConnectionHandler",
|
reason: str,
|
except_app: str = "",
|
except_task: str = "",
|
) -> None:
|
device_id = str(getattr(conn, "device_id", "") or "")
|
if not device_id:
|
return
|
payload: Dict[str, Any] = {
|
"device_id": device_id,
|
"reason": reason or "server",
|
"except_app_id": except_app or "",
|
"except_task_id": except_task or "",
|
}
|
_schedule_close_parent(payload)
|
|
|
def _schedule_close_parent_matching(
|
conn: "ConnectionHandler",
|
session: TaskSession,
|
reason: str,
|
) -> None:
|
device_id = str(getattr(conn, "device_id", "") or getattr(session, "device_id", "") or "")
|
if not device_id:
|
return
|
status = "done" if getattr(session, "status", "") == "completed" else "cancelled"
|
payload: Dict[str, Any] = {
|
"device_id": device_id,
|
"reason": reason or "",
|
"status": status,
|
"match_app_id": session.app_id or "",
|
"match_task_id": session.task_id or "",
|
}
|
_schedule_close_parent(payload)
|
|
|
def _schedule_close_parent(payload: Dict[str, Any]) -> None:
|
async def _run():
|
try:
|
from config.manage_api_client import close_parent_immediate_tasks
|
|
await close_parent_immediate_tasks(payload)
|
except Exception as exc:
|
logger.bind(tag=TAG).warning(f"关闭家长即时任务失败: {exc}")
|
|
try:
|
loop = asyncio.get_running_loop()
|
except RuntimeError:
|
return
|
loop.create_task(_run())
|
|
|
async def _upsert_task_instance(payload: Dict[str, Any]) -> bool:
|
from config.manage_api_client import upsert_task_instance
|
|
data = await upsert_task_instance(payload)
|
return data is not None
|
|
|
def _schedule_upsert(payload: Dict[str, Any], session: Optional["TaskSession"] = None) -> None:
|
async def _run():
|
try:
|
ok = await _upsert_task_instance(payload)
|
if not ok and session is not None:
|
session._skip_upsert = True
|
logger.bind(tag=TAG).warning("TaskInstance 上报失败,本会话不再重复上报")
|
except Exception as exc:
|
if session is not None:
|
session._skip_upsert = True
|
logger.bind(tag=TAG).warning(f"TaskInstance 上报失败: {exc}")
|
|
try:
|
loop = asyncio.get_running_loop()
|
except RuntimeError:
|
return
|
loop.create_task(_run())
|