🎯 설계 핵심 5
- 두 기능을 분리해 각각 최적 경로로. ① 받은 전화 = 갤럭시 네이티브 녹음파일 자동수집 → 서버 STT/AI ② 부재중 = Android
telephony단말 직접 SMS(MVP) / 050·070 착신전환(제품화). - 최대 블로커는 "갤럭시 통화녹음 폴더 보호". 이미 telinfo가 부딪힌 문제(
/Recordings/Call/일반 권한 read 불가). 해결 = SAF(Storage Access Framework) 디렉토리 영속 권한 — 사용자가 폴더를 1회 지정하면 재부팅 후에도 read 가능, Play 정책 안전. - 녹음은 새로 안 한다. OS가 만든 파일을 읽기만. 마이크·접근성 권한 미사용 → Play 통화녹음 금지(2022.5) 조항 비해당. 자체 녹음 시도는 금물.
- 기존 앱린다 인프라 재사용. S3 업로드(s3.service)·BullMQ 큐·Drizzle/Postgres·SSE 진행률·Gemini(agent-model-settings)·디바이스 토큰 인증 — 신규 인프라 거의 0.
- STT는 리턴제로(RTZR OpenAPI). 한국어 전화망 CER 3.56% 1위 + 2화자 diarization 배치. PIPA 민감 고객용 온프렘 Whisper 옵션은 후순위.
00 현재 상태 — 이미 있는 것
기존 mobile/telinfo (Flutter) 가 상당 부분을 구현해 둠. 이 위에 파이프라인을 얹는다.
✅ 구현 완료 (telinfo)
- 통화로그 수집·일괄 업로드 —
call_log,_bulkUploadCallLogs() - 녹음 파일 목록·재생·업로드 —
RecordingInfo,recording_player_page,_bulkUploadRecordings() - 연락처·즐겨찾기 —
flutter_contacts - SMS 권한·송수신 기반 —
telephony 0.2.0 - 서버 업로드 클라이언트 —
RindaApiClient(디바이스 토큰, m4a MIME, s3.service 정합)
🚧 막힌 곳 / 미구현
- 갤럭시 녹음폴더 보호 —
/Recordings/Call/일반 권한 read 불가 (telinfo home_page:1127 안내문 존재) → §04에서 해결 - 신규 녹음 자동 감지·자동 업로드 — 현재 수동 일괄만
- STT → 액션플랜 백엔드 파이프라인 — 미구현
- 부재중 자동 SMS 트리거 — telephony 기반만 있고 자동화 로직 없음
- 액션플랜 모바일 표시 UI — 미구현
01 전체 아키텍처
단말 OS 제약을 우회하는 두 플로우. 받은 전화는 단말이 만든 녹음을 끌어오고, 부재중은 단말/서버에서 즉시 응답한다.
📞 받은 전화 → 액션플랜
📵 부재중 → 자동 SMS
무료·즉시. iOS 불가. SEND_SMS 권한.
iOS 포함 전 단말. 운영비 발생.
기술 스택 결정
| 레이어 | 선택 | 근거 (기존 자산/제약) |
|---|---|---|
| 모바일 | Flutter (telinfo 확장) | 이미 통화로그·녹음·SMS·업로드 구현됨. 재작성 불필요 |
| 녹음 수집 | SAF 영속권한 + 주기 스캔 | 갤럭시 보호폴더 유일 정공법. Play 안전 (마이크/접근성 미사용) |
| 백엔드 | Elysia/Bun routes→services→db | 앱린다 표준 3-layer |
| 파일저장 | S3 (s3.service) | RindaApiClient가 이미 업로드 중 |
| 비동기 처리 | BullMQ + Redis | STT·LLM은 수초~수십초 → 큐 필수. retry·backoff 내장 |
| DB | Postgres (Drizzle), UUIDv7 | 영속·공유 엔티티 SSOT. keyset 페이지네이션 |
| STT | 리턴제로 RTZR OpenAPI | 한국어 전화망 CER 3.56% 1위, 2화자 diarization 배치 |
| LLM | Gemini (gemini-3-flash-preview) | agent-model-settings.service SSOT, 비용·속도 |
| 실시간 알림 | SSE + Redis Pub/Sub | 앱린다 진행률 UI 표준. 액션플랜 완료 푸시 |
| 부재중 SMS | telephony(MVP) → 050/070(확장) | Android 즉시 무료 → iOS 포함 시 서버 경유 |
02 핵심 블로커 해결 — 갤럭시 녹음폴더 접근
telinfo가 이미 부딪힌 "/Recordings/Call/ 일반 권한 read 불가". 이게 받은 전화 기능의 사활을 가른다.
| 방법 | 접근 | Play 정책 | UX | 판정 |
|---|---|---|---|---|
READ_MEDIA_AUDIO | 일반 미디어만 | 안전 | 무마찰 | ✗ 보호된 /Recordings/Call 미인덱싱 |
MANAGE_EXTERNAL_STORAGE | 전체 파일 | 거부 위험 | 무마찰 | △ 사이드로드/엔터프라이즈만 |
| SAF 디렉토리 영속권한 | 지정 폴더 read/write | 안전 | 최초 1회 폴더 선택 | ◎ 채택 |
✅ 채택: SAF (Storage Access Framework) 영속 권한
사용자가 최초 1회 ACTION_OPEN_DOCUMENT_TREE 로 /Recordings/Call 폴더를 선택 → takePersistableUriPermission() 로 영속화 → 재부팅 후에도 DocumentFile/ContentResolver 로 read. 마이크·접근성 권한 불필요 → Play 통화녹음 금지 조항 비해당. Flutter는 saf_util/saf_stream 패키지 또는 플랫폼 채널로 구현.
// telinfo: SAF 영속권한 획득 + 신규 녹음 스캔 (의사코드) final treeUri = await saf.openDocumentTree(); // 1회: /Recordings/Call 선택 await saf.takePersistableUriPermission(treeUri); // 재부팅 내성 prefs.setString('callDirUri', treeUri.toString()); // WorkManager 주기 작업 (15분) — 업로드 안 된 새 파일만 diff final files = await saf.listFiles(treeUri); final fresh = files.where((f) => !uploaded.contains(f.id) && f.name.endsWith('.m4a')); for (final f in fresh) { final callId = await api.uploadRecording(f, matchByTimestamp: callLog); // 통화로그와 시각 매칭 uploaded.add(f.id); }
🔗 통화로그 ↔ 녹음파일 매칭
녹음 파일명/수정시각과 call_log 의 통화 시작시각·번호를 매칭해 "누구와의 통화인지"를 확정 → 연락처/딜에 연결. 파일명 규칙(Call recording_{상대}_{timestamp}.m4a)이 OEM별로 달라 시각 기반 매칭을 1차로, 파일명 파싱을 보조로.
03 데이터 모델 (Drizzle / Postgres)
영속·공유 엔티티는 Postgres SSOT. 신규 PK는 UUIDv7, 조회는 keyset.
// elysia-server/src/db/schema/calls.ts export const calls = pgTable('calls', { id: uuid().default(sql`uuidv7()`).primaryKey(), workspaceId: uuid().notNull().references(() => workspaces.id), contactId: uuid().references(() => contacts.id), // 매칭된 거래처 direction: text({ enum: ['inbound','outbound','missed'] }).notNull(), peerNumber: text().notNull(), startedAt: timestamp().notNull(), durationSec: integer(), recordingS3Key: text(), // 녹음 있을 때만 status: text({ enum: ['uploaded','transcribing','analyzing','done','failed'] }).notNull(), createdAt: timestamp().defaultNow(), }); export const transcripts = pgTable('transcripts', { id: uuid().default(sql`uuidv7()`).primaryKey(), callId: uuid().notNull().references(() => calls.id), provider: text().default('rtzr'), fullText: text(), segments: jsonb(), // [{speaker, start, end, text}] 화자라벨 confidence: real(), }); export const actionPlans = pgTable('action_plans', { id: uuid().default(sql`uuidv7()`).primaryKey(), callId: uuid().notNull().references(() => calls.id), summary: text(), customerNeeds: jsonb(), // 추출된 고객 니즈 dealStage: text(), // 딜 단계 추론 nextActions: jsonb(), // [{action, dueDate, priority}] createdAt: timestamp().defaultNow(), }); export const autoReplyLogs = pgTable('auto_reply_logs', { id: uuid().default(sql`uuidv7()`).primaryKey(), workspaceId: uuid().notNull(), peerNumber: text().notNull(), message: text().notNull(), channel: text({ enum: ['device_sms','alimtalk','sms_fallback'] }).notNull(), sentAt: timestamp().defaultNow(), });
04 STT → 액션플랜 파이프라인 (BullMQ)
업로드 후 비동기 2단계 잡. 각 단계 실패는 BullMQ retry·backoff. 진행률은 SSE로 모바일에 푸시.
// elysia-server/src/services/call-analysis.service.ts (핵심만) // 1) 리턴제로 배치 STT (화자분리) const stt = await rtzr.transcribe({ audioUrl: s3.signedUrl(call.recordingS3Key), config: { diarization: { enable: true, speakers: 2 }, language: 'ko' }, }); // 2) Gemini 액션플랜 (구조화 출력) const plan = await gemini.generate({ model: settings.get('call-analysis'), // agent-model-settings SSOT systemPrompt: SALES_ANALYSIS_PROMPT, // 영업 도메인: 니즈·딜단계·이의제기·next step input: formatSpeakerLabeled(stt.segments), // "[상담원] … [고객] …" responseSchema: ActionPlanSchema, // summary, customerNeeds, dealStage, nextActions[] }); // 3) 저장 + SSE 푸시 await db.insert(actionPlans).values({ callId: call.id, ...plan }); redis.publish(`call:${call.id}`, { status: 'done' });
⚙️ 영업 특화 = 차별화 지점
단순 요약(에이닷·갤럭시AI 수준)이 아니라 영업 도메인 프롬프트로 ① 고객 니즈/페인포인트 ② 딜 단계 추론 ③ 이의제기 대응 ④ 구체적 next action(기한·우선순위)을 구조화 출력. 통화 N건을 거래처/딜 단위로 누적해 파이프라인을 만드는 것이 기성 통화요약 대비 핵심 우위.
05 부재중 자동 SMS
MVP는 Android 단말 직접 발송(무료·즉시), 제품화·iOS 대응은 050/070 착신전환 + 서버.
① MVP — telephony (Android)
// telinfo: 부재중 감지 → 자동 SMS Telephony.instance.listenIncomingSms; // 권한 // PhoneState 부재중 이벤트 / call_log 폴링 if (call.type == CallType.missed && settings.autoReplyEnabled) { await Telephony.instance.sendSms( to: call.number, message: settings.autoReplyText, // "현재 출장중…" ); api.logAutoReply(call.number, 'device_sms'); }
- 무료·즉시 단말에서 바로 발송
- 제약 Android 전용, SEND_SMS 권한, Play 심사 유의
- 발송 조건(업무시간·연락처별·중복방지) 설정 제공
② 제품화 — 050/070 + 서버
- 무응답 조건부 착신전환
*61*→ 050/070 가상번호(서버 종단) - 서버 webhook 발신번호·시각 수신 → 음성안내 재생
- 카카오 알림톡 + 대체문자 발송 (실패 시 SMS fallback)
- 크로스플랫폼 iOS 포함 전 단말, 단말 권한 불필요
- 비용 가상번호 월정액 + 발송 건당
주의: 050 안심번호는 알림톡 발신번호로 못 씀 → 알림톡은 등록된 비즈 발신번호, 050은 수신 라우팅 전용으로 분리.
06 구현 단계 (DAG)
위상정렬 레이어. 같은 레이어는 병렬 가능, *=Critical Path, [S≤50 / M≤150 / L≤300 LOC].
DB 스키마 + 업로드 라우트
calls/transcripts/action_plans/auto_reply_logs 마이그레이션 + 녹음·통화로그 업로드 라우트(workspaceAuth 매크로).
bun db:generate4개 테이블 (UUIDv7)- 업로드 라우트 → S3 + calls row 생성
- RindaApiClient 정합 확인
리턴제로 / Gemini 연동 병렬 N0.1
RTZR OpenAPI 키·클라이언트, Gemini는 기존 agent-model-settings 재사용. Infisical에 시크릿 등록.
부재중 SMS (telephony) 병렬
telinfo에 부재중 감지 → 자동 SMS 발송 + 서버 로그. 가장 빨리 체감되는 기능.
STT→분석 파이프라인 (BullMQ) N0.1·N0.2
call-processing 큐: transcribe → analyze 2 잡. retry/backoff. SSE 진행률.
- transcribe 잡: 리턴제로 배치 + 2화자 diarization → transcripts
- analyze 잡: Gemini 영업 프롬프트 → action_plans
- Redis pub/sub → SSE 푸시
갤럭시 녹음 자동 감지 (SAF) N1.1
§02 SAF 영속권한 + WorkManager 주기 스캔 → 신규 .m4a diff 자동 업로드 + 통화로그 매칭.
액션플랜 모바일 UI (SSE) N1.1
통화별 요약·니즈·딜단계·next action 카드 + 거래처 단위 파이프라인 뷰. SSE 실시간 갱신.
통합 E2E + 번인 전체
실기기(갤럭시) 통화→녹음→자동업로드→액션플랜→표시 happy/edge/error 케이스. --repeat-each 번인. send-ci.sh 통과.
07 리스크 & 대응
| 리스크 | 영향 | 대응 |
|---|---|---|
| 갤럭시 자동녹음 미설정/지역차단 | 입력 0 | 온보딩에서 자동녹음 ON 가이드 + 미설정 감지 경고. 한국 타깃은 토글 노출됨 |
| SAF 폴더 선택 UX 마찰 | 도입 이탈 | 스크린샷 가이드 + 1회만. 미부여 시 수동 업로드 fallback |
| OEM별 녹음 폴더·파일명 상이 | 매칭 실패 | 삼성 우선 지원 명시. 시각 기반 매칭 1차 + 파일명 파싱 보조 |
| iOS 자동화 불가 | iOS 미지원 | share sheet 수동 업로드 + 부재중은 050/070 서버. 갤럭시 우선 포지셔닝 |
| Play 심사 (SMS/저장소 권한) | 출시 지연 | READ_MEDIA_AUDIO 대신 SAF, 마이크/접근성 미요청. SEND_SMS는 핵심기능 정당성 문서화 |
| 통비법·녹음 고지 | 법적 | 일방 당사자 녹음(합법) 전제. 녹음 고지음은 OS 강제. 약관·동의 UX |
| STT/LLM 비용 증가 | 마진 | 배치 처리(실시간X), 통화당 ~₩40 추정. 무음/초단통화 스킵, 길이 기반 과금 모니터 |
| 부재중 SMS 중복·오발송 | 고객 불만 | 번호별 쿨다운·업무시간·연락처 화이트리스트. auto_reply_logs로 멱등 |
08 MVP 범위 & 일정
🎯 MVP (갤럭시, 2~3주)
- 받은 전화 자동녹음 → SAF 자동수집 → 리턴제로 STT → Gemini 액션플랜 → 모바일 표시
- 부재중 telephony 단말 직접 SMS (조건·로그)
- 거래처 매칭 통화로그↔녹음 시각 매칭
- 플랫폼 갤럭시(삼성) 전용
📈 v2 (제품화)
- 거래처/딜 파이프라인 누적 뷰 + CRM 양방향
- 부재중 050/070 착신전환 + 알림톡 (iOS 커버)
- iOS share sheet 수동 + 부재중 서버
- 후속 자동화 시퀀스 메일·리마인더 연동 (앱린다 기존 시퀀스 재사용)
- 온프렘 STT PIPA 민감 고객용 Whisper 옵션
✅ 왜 이 계획이 최적인가
① 기존 telinfo·앱린다 인프라 재사용으로 신규 구축 최소 ② 단말 OS 제약을 정면 우회(녹음 안 만들고 읽기만 + 착신전환) ③ 한국어 전화망 최고 정확도(리턴제로) ④ 영업 도메인 액션플랜으로 기성 통화요약(에이닷·갤럭시AI) 대비 명확한 차별화 ⑤ 갤럭시 MVP로 2~3주 내 VITO 이탈 사용자 체감 가치 전달.