Skip to content

Commit 9bdc4d7

Browse files
authored
Merge pull request #72 from Ridealist/develop
Develop
2 parents d7e5f61 + 3ee89bd commit 9bdc4d7

14 files changed

Lines changed: 160 additions & 41 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,7 @@ sudo nginx -t
596596

597597
세션 시작 시 자동으로 LiveKit Egress API가 호출되어 모든 참가자(학생 + AI) 음성이 혼합된 MP3 파일이 S3에 저장됩니다.
598598

599-
- **저장 경로**: `s3://tblt-agent-recordings/recordings/{룸명}--{타임스탬프}.mp3`
599+
- **저장 경로**: `s3://tblt-agent-recordings/recordings/{룸명}-{LiveKit room SID}-{타임스탬프}.mp3`
600600
- **트리거**: `session.start()` 직후 자동 시작
601601
- **종료**: 룸 `disconnected` 이벤트 발생 시 자동 종료
602602
- **관련 코드**: `agent/egress_recorder.py`
@@ -651,7 +651,7 @@ SINCE_YYYYMMDD=20260701 scripts/download_s3_recordings.sh
651651
SINCE_YYYYMMDD=20260601 scripts/download_s3_recordings.sh ./tmp/recordings
652652
```
653653

654-
이 필터는 S3 `LastModified`가 아니라 파일명 규칙 `recordings/{룸명}--YYYYMMDD_HHMMSS.mp3`의 날짜 부분을 기준으로 동작한다.
654+
이 필터는 S3 `LastModified`가 아니라 파일명 규칙 `recordings/{룸명}-{LiveKit room SID}-YYYYMMDD_HHMMSS.mp3`의 날짜 부분을 기준으로 동작한다.
655655

656656
사용 가능한 환경변수:
657657

agent/egress_recorder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def __init__(self, room_name: str, session_id: str) -> None:
2525
self.session_id = session_id
2626
self.egress_id: str | None = None
2727
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
28-
self._filepath = f"recordings/{room_name}--{ts}.mp3"
28+
self._filepath = f"recordings/{room_name}-{session_id}-{ts}.mp3"
2929

3030
@property
3131
def filepath(self) -> str:

agent/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,9 @@ def _resolve_realtime_activity_context(ctx: JobContext) -> dict:
331331
return _normalize_activity_context(activity_context)
332332

333333
room_name = getattr(ctx.room, "name", "")
334-
if isinstance(room_name, str) and room_name.startswith("eval-"):
334+
if isinstance(room_name, str) and (
335+
room_name.startswith("eval-") or room_name.startswith("eval_")
336+
):
335337
return {
336338
"activity_type": "free_conversation",
337339
"evaluation_id": "pretest_6_10",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import re
2+
import sys
3+
from types import SimpleNamespace
4+
5+
sys.modules.setdefault(
6+
"livekit",
7+
SimpleNamespace(api=SimpleNamespace(LiveKitAPI=object, S3Upload=object)),
8+
)
9+
from egress_recorder import EgressRecorder
10+
11+
12+
def test_egress_recording_filepath_includes_room_sid_and_timestamp() -> None:
13+
recorder = EgressRecorder(
14+
"task_11_17_yujeong_jeong_202315c",
15+
"RM_NzGHwKNz7iAu",
16+
)
17+
18+
assert re.fullmatch(
19+
r"recordings/task_11_17_yujeong_jeong_202315c-RM_NzGHwKNz7iAu-\d{8}_\d{6}\.mp3",
20+
recorder.filepath,
21+
)

agent/tests/test_logger.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def test_conversation_logger_dual_writes_pending_events_to_mock_writer(tmp_path)
135135
logger.update_metadata(
136136
{
137137
"egress_id": "egress-123",
138-
"recording_path": "recordings/realtime-minji--20260612_010101.mp3",
138+
"recording_path": "recordings/realtime_minji-RM_livekit-20260612_010101.mp3",
139139
}
140140
)
141141
logger.close()
@@ -348,7 +348,7 @@ def opener(request, timeout):
348348

349349
writer = SupabaseConversationWriter(
350350
"livekit-room-sid",
351-
"eval-9-minji-12345678",
351+
"eval_9_2_minji_12345678",
352352
{
353353
"activity_type": "free_conversation",
354354
"agent_mode": "realtime",
@@ -408,7 +408,7 @@ def opener(request, timeout):
408408
}
409409
writer = SupabaseConversationWriter(
410410
"livekit-room-sid",
411-
"eval-9-minji-12345678",
411+
"eval_9_2_minji_12345678",
412412
metadata,
413413
SupabaseConversationConfig(url="http://supabase.test", key="secret"),
414414
opener=opener,
@@ -523,7 +523,7 @@ def opener(request, timeout):
523523
metadata = {
524524
"agent_mode": "pipeline",
525525
"egress_id": "egress-123",
526-
"recording_path": "recordings/9-1--20260612_010101.mp3",
526+
"recording_path": "recordings/9-1-RM_livekit-20260612_010101.mp3",
527527
}
528528
writer.sync_session_metadata(metadata)
529529
writer.end_session(metadata)

client/app/admin/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,8 @@ function inferRoomSessionPurpose(room: RealtimeRoomStatus): SessionPurpose | und
295295
if (room.sessionPurpose === 'evaluation' || room.sessionPurpose === 'practice') {
296296
return room.sessionPurpose;
297297
}
298-
if (room.name.startsWith('eval-')) return 'evaluation';
299-
if (room.name.startsWith('task-')) return 'practice';
298+
if (room.name.startsWith('eval-') || room.name.startsWith('eval_')) return 'evaluation';
299+
if (room.name.startsWith('task-') || room.name.startsWith('task_')) return 'practice';
300300
return undefined;
301301
}
302302

client/app/api/rooms/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,13 @@ function parseRealtimeRoomMetadata(metadata?: string): {
9696
}
9797

9898
function isRealtimeRoomName(name: string) {
99-
return name.startsWith('realtime-') || name.startsWith('eval-') || name.startsWith('task-');
99+
return (
100+
name.startsWith('realtime-') ||
101+
name.startsWith('eval-') ||
102+
name.startsWith('eval_') ||
103+
name.startsWith('task-') ||
104+
name.startsWith('task_')
105+
);
100106
}
101107

102108
async function getRoomCounts(svc: RoomServiceClient, roomName: string): Promise<RoomCounts> {

client/app/api/token/route.ts

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,23 +127,27 @@ export async function POST(req: Request) {
127127
typeof body?.display_name === 'string' && body.display_name.trim()
128128
? body.display_name.trim()
129129
: studentDefaultDisplayName(student);
130-
const roomName = typeof body?.room_name === 'string' ? body.room_name.trim() : '';
131-
if (!displayName || !roomName) {
130+
const requestedRoomName = typeof body?.room_name === 'string' ? body.room_name.trim() : '';
131+
if (!displayName || !requestedRoomName) {
132132
return NextResponse.json(
133133
{ error: 'display_name and room_name are required.' },
134134
{ status: 400, headers: { 'Cache-Control': 'no-store' } }
135135
);
136136
}
137137
const participantIdentity = createStudentParticipantIdentity(student);
138138
const config = await readRuntimeConfig();
139-
const agentMode = inferAgentMode(body?.agent_mode, roomName, config.agentMode);
139+
const agentMode = inferAgentMode(body?.agent_mode, requestedRoomName, config.agentMode);
140140
const sessionActivity =
141141
agentMode === 'realtime'
142-
? await readSessionActivityContext(body, roomName, config.sessionPurpose)
142+
? await readSessionActivityContext(body, requestedRoomName, config.sessionPurpose)
143143
: undefined;
144+
const roomName =
145+
agentMode === 'realtime' && sessionActivity
146+
? canonicalRealtimeRoomName(requestedRoomName, displayName, student, sessionActivity)
147+
: requestedRoomName;
144148
if (agentMode === 'realtime' && config.realtimeResetting) {
145149
logTokenEvent('rejected realtime token during reset', {
146-
roomName,
150+
roomName: requestedRoomName,
147151
studentNumber: student.studentNumber,
148152
requestedAgentMode: body?.agent_mode ?? null,
149153
requestedActivityType: body?.activity_type ?? null,
@@ -178,6 +182,7 @@ export async function POST(req: Request) {
178182

179183
logTokenEvent('issuing participant token', {
180184
roomName,
185+
requestedRoomName,
181186
displayName,
182187
studentId: student.id,
183188
studentNumber: student.studentNumber,
@@ -279,6 +284,53 @@ function safeIdentityPart(value: string) {
279284
);
280285
}
281286

287+
function safeRoomNamePart(value: string) {
288+
return (
289+
value
290+
.trim()
291+
.toLowerCase()
292+
.replace(/[^a-z0-9]+/g, '_')
293+
.replace(/^_+|_+$/g, '')
294+
.slice(0, 40) || 'student'
295+
);
296+
}
297+
298+
function isActivityRoomName(roomName: string) {
299+
return (
300+
roomName.startsWith('eval-') ||
301+
roomName.startsWith('eval_') ||
302+
roomName.startsWith('task-') ||
303+
roomName.startsWith('task_')
304+
);
305+
}
306+
307+
function roomSuffixFromRequested(roomName: string) {
308+
const match = roomName.match(/[_-]([a-zA-Z0-9]{8})$/);
309+
return match?.[1] ?? String(Date.now()).slice(-8);
310+
}
311+
312+
function canonicalRealtimeRoomName(
313+
requestedRoomName: string,
314+
displayName: string,
315+
student: StudentSession,
316+
sessionActivity: SessionActivityContext
317+
) {
318+
if (!isActivityRoomName(requestedRoomName)) return requestedRoomName;
319+
320+
const prefix =
321+
sessionActivity.sessionPurpose === 'evaluation' ||
322+
sessionActivity.activityType === 'free_conversation'
323+
? 'eval'
324+
: 'task';
325+
return [
326+
prefix,
327+
student.classNumber,
328+
student.rollNumber,
329+
safeRoomNamePart(displayName),
330+
roomSuffixFromRequested(requestedRoomName),
331+
].join('_');
332+
}
333+
282334
function createStudentParticipantIdentity(student: StudentSession) {
283335
return `student-${safeIdentityPart(student.studentNumber)}-${Math.floor(Math.random() * 10_000)}`;
284336
}
@@ -295,7 +347,14 @@ async function readRuntimeConfig(): Promise<RuntimeConfig> {
295347
}
296348

297349
function inferAgentMode(value: unknown, roomName: string, fallback: AgentMode): AgentMode {
298-
if (roomName.startsWith('eval-') || roomName.startsWith('task-')) return 'realtime';
350+
if (
351+
roomName.startsWith('eval-') ||
352+
roomName.startsWith('eval_') ||
353+
roomName.startsWith('task-') ||
354+
roomName.startsWith('task_')
355+
) {
356+
return 'realtime';
357+
}
299358
if (roomName.startsWith('realtime-')) return 'realtime';
300359
return normalizeAgentMode(value ?? fallback);
301360
}
@@ -310,8 +369,8 @@ type SessionPurposeSignal = {
310369
};
311370

312371
function parseRoomSessionPurpose(roomName: string): SessionPurpose | undefined {
313-
if (roomName.startsWith('eval-')) return 'evaluation';
314-
if (roomName.startsWith('task-')) return 'practice';
372+
if (roomName.startsWith('eval-') || roomName.startsWith('eval_')) return 'evaluation';
373+
if (roomName.startsWith('task-') || roomName.startsWith('task_')) return 'practice';
315374
return undefined;
316375
}
317376

client/components/app/lobby-view.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ export function LobbyView({
9898
return (
9999
name
100100
.toLowerCase()
101-
.replace(/[^a-z0-9]+/g, '-')
102-
.replace(/^-+|-+$/g, '')
101+
.replace(/[^a-z0-9]+/g, '_')
102+
.replace(/^_+|_+$/g, '')
103103
.slice(0, 40) || 'student'
104104
);
105105
}
@@ -113,7 +113,8 @@ export function LobbyView({
113113
function makeActivityRoomName(name: string, activityType: ActivityType) {
114114
const prefix = activityType === 'free_conversation' ? 'eval' : 'task';
115115
const slug = name ? makeStudentSlug(name) : 'student';
116-
return `${prefix}-${activeClass ?? 'class'}-${slug}-${makeRoomSuffix()}`;
116+
// Realtime rooms identify one logged-in student; pipeline rooms use the active class/group.
117+
return `${prefix}_${student.classNumber}_${student.rollNumber}_${slug}_${makeRoomSuffix()}`;
117118
}
118119

119120
function handleJoin() {

client/lib/conversation-dashboard.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ function textNumber(value: unknown): string | undefined {
5656
function parseRoomClass(room: string): string | undefined {
5757
const pipelineMatch = room.match(/^(\d+)-/);
5858
if (pipelineMatch) return pipelineMatch[1];
59-
const realtimeMatch = room.match(/^(?:eval|task)-([^-]+)-/);
59+
const realtimeMatch = room.match(/^(?:eval|task)[-_]([^-_]+)[-_]/);
6060
return realtimeMatch?.[1];
6161
}
6262

@@ -68,7 +68,13 @@ function parsePipelineRoom(room: string): { cls: string; grp: string } {
6868
}
6969

7070
function isRealtimeRoomName(room: string) {
71-
return room.startsWith('realtime-') || room.startsWith('eval-') || room.startsWith('task-');
71+
return (
72+
room.startsWith('realtime-') ||
73+
room.startsWith('eval-') ||
74+
room.startsWith('eval_') ||
75+
room.startsWith('task-') ||
76+
room.startsWith('task_')
77+
);
7278
}
7379

7480
export function buildLogSessionsQuery(filters: DashboardFilters): string {
@@ -89,8 +95,8 @@ export function inferDashboardSessionPurpose(
8995
if (rawActivity === 'free_conversation' || rawActivity === 'task_solution') {
9096
return getSessionPurposeForActivity(rawActivity);
9197
}
92-
if (session.room.startsWith('eval-')) return 'evaluation';
93-
if (session.room.startsWith('task-')) return 'practice';
98+
if (session.room.startsWith('eval-') || session.room.startsWith('eval_')) return 'evaluation';
99+
if (session.room.startsWith('task-') || session.room.startsWith('task_')) return 'practice';
94100
return undefined;
95101
}
96102

0 commit comments

Comments
 (0)