wangzhibo
2026-10-08 220e4fab27b7f419b3e89e2f2d28ae789aac09d7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
"""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())