DeviceAgent ↔ AI-Agent 구현 요청서
2026-09-29 구현 범위: 화자 등록·상태 전달·목록·삭제·서버 관심사 조회가 대상이다. 대화내역 저장·조회·삭제·용량 조회·실시간 저장 알림은 다음 차수로 제외했으며 현재 공개 인터페이스에 없다. 발음 보조 값도 외부에서 받거나 보내지 않고 AI-Agent 내부에서만 처리한다.
대상: SoC DeviceAgent 담당과 AI-Agent Service 담당. 이 문서의 구현 범위는 기기 내부 Binder 경계다. APP·Backend·IoTAgent의 외부 통신 설계는 각 담당이 협의하며, 이 문서에서 토픽이나 Backend API를 지정하지 않는다.
상세 연동 보강: 10~15절에서 누락값·자료형, 호출자별 반환값, 관리 결과의 필드 조건, Bundle 왕복 예시, 오류·경합 시험을 확인한다. 표의 송신 규칙과 현재 수신 코드의 검사 범위는 구분한다.
1. 책임과 연결
| 항목 | DeviceAgent | AI-Agent |
|---|---|---|
| 등록 제어 | 검증 후 등록 제어 Bundle 전달 | 녹음·검사·생성·취소 실행 |
| 상태 | 수신 상태를 등록 App과 외부 통신 측에 중계 | 상태·순번·진행률·종료 여부 생성 |
| 프로필 | 임베딩과 식별 정보를 인수하고 결과 반환 | voiceId·대표 임베딩·닉네임 생성/보관 |
| 관리 | 목록·삭제 요청과 결과 중계 | 로컬 프로필 관리 |
| 관심사 | 조회를 외부로 연결하고 결과 반환 | 인식된 voiceId로 조회. 서버 원본을 대체하지 않음 |
AI-Agent 패키지는 com.skmagic.ondeviceai.agent. callback은 CALLER_LLM=1이다. CALLER_ONDEVICE=7은 별도 앱 항목이므로 이 경계에 사용하지 않는다.
| 방향 | 실제 호출 형태 | 결과 전달 |
|---|---|---|
| AI-Agent → DeviceAgent | sendModuleCommand(input: Bundle, output: Bundle): Int |
즉시 값은 접수/거절; 업무 결과는 별도 callback |
| DeviceAgent → AI-Agent | onModuleCallbackLoop(CALLER_LLM, Bundle) → onModuleCallback() |
AI-Agent가 method별로 처리 |
SoC는 ModuleCmdCallbackPolicy의 AI-Agent 수신 목록에 등록 제어·관리 요청·voiceProfileCommitResult·voicePersonalizationResult를 상수로 등록한다. UID/패키지 검사와 callback 대상은 서로 다른 검사이며 숫자 1만으로 호출 권한을 증명하지 않는다.
2. 공통 값과 전달 규칙
| 필드 | Android 타입 | 정의 |
|---|---|---|
| method | String | 아래 표의 정확한 메서드명 |
| contractVersion | String | 등록/프로필: voice-profile-v2, 구형 관심사 호환: voice-memory-v1, 개인화 조회: voice-personalization-v1 |
| requestId | String | 요청과 결과 연결값. 전달 중 바꾸지 않음 |
| voiceId | String | 생성된 화자 프로필 키. 필요한 요청에만 포함 |
숫자를 문자열로 바꾸지 않는다. 등록 요청의 userId는 기존 클라이언트 호환 입력이며 실제 프로필 키를 대체하지 않는다. 원본 PCM/WAV는 이 경계로 전달하지 않는다.
3. DeviceAgent가 AI-Agent로 전달할 요청
등록 제어는 voice-profile-v2이며 공통 필드와 UI가 보낸 issuedAtMs:Long을 보존한다.
| method | 추가 입력 | AI-Agent 동작 / 받게 되는 결과 |
|---|---|---|
| getVoiceProfileEnrollmentState | 없음 | 현재 상태 callback |
| startVoiceProfileEnrollment | nickname:String, locale:String; 기존 클라이언트는 userId:String도 전송 | 녹음 시작. 같은 활성/보존 세션의 requestId는 현재 상태만 반환 |
| retryVoiceProfileSet | setIndex:Int | 현재 재시도 가능한 세트 처리. 임의 세트 선택 API 아님 |
| pauseVoiceProfileEnrollmentForExit | setIndex:Int | 나가기 확인 중 녹음 일시 정리. 별도 pause ACK 없음 |
| cancelVoiceProfileEnrollment | reason:String | 취소 처리와 상태 callback |
| setVoiceProfilePersonalization | enabled:Boolean | 개인화 선택 후 프로필 생성. 관심사 목록 저장이 아님 |
| retryVoiceProfileCreation | 없음 | 재시도 가능한 생성 실패 단계에서 재생성 |
| retryVoiceProfileDelivery | 없음 | 기존 pending 프로필 재전달 |
| closeVoiceProfileRegistration | 없음 | 등록 UI 종료 처리. 완료 프로필 삭제 아님 |
외부 시작 요청은 현재 DeviceAgent에서 등록 화면을 여는 경로다. 등록 App의 시작 버튼 요청을 AI-Agent로 전달하는 경로와 합치지 않는다. 화면 실행 계약은 등록 App 문서에 있다.
| 관리 method | 버전 | 추가 입력 | 결과 method |
|---|---|---|---|
| listVoiceProfiles | voice-profile-v2 | 없음 | voiceProfileListResult |
| deleteVoiceProfile | voice-profile-v2 | voiceId:String | voiceProfileDeleteResult |
현재 DeviceAgent는 관리 요청을 IoTAgent 호출자에게서 수신해 중계한다. 외부 원천이 APP인지 관제인지는 이 Binder 경계의 구분값이 아니다. 구형 set/getVoiceProfileInterests는 신규 구현 대상으로 사용하지 않는다.
4. AI-Agent 상태를 복사 전달
method=voiceProfileStateChanged, contractVersion=voice-profile-v2. AI-Agent가 생성하고 DeviceAgent는 등록 App(8)과 IoTAgent(2)에 복사 전달한다. IoTAgent 이후 전달 방법은 외부 담당 범위다.
| 필드 | 타입 | 값·의미 |
|---|---|---|
| requestId | String | 등록 요청 ID |
| stateSequence | Long | 0 이상 상태 순번. 같은 세션에서 사용 |
| state | String | 아래 상태 enum 이름 |
| setIndex / setCount | 각각 Int | 1부터 시작; setIndex ≤ setCount |
| progress | Int | 0~100 |
| reasonCode | String | 빈 문자열 또는 오류·대기 사유 |
| terminal | Boolean | 해당 등록 처리 종료 여부 |
| nickname | String | 표시 이름. 초기에는 빈 값 가능 |
| voiceId | String | 생성 후 포함. COMPLETED에서는 유효한 값 |
상태값: WAITING_START, RECORDING_SET, SET_ACCEPTED, SET_REJECTED, PERSONALIZATION_SETTINGS, CREATING_LOCAL_PROFILE, PENDING_DEVICE_DELIVERY, COMPLETED, PROFILE_FAILED, DELIVERY_FAILED, CANCELLED.
DeviceAgent는 타입·상태값·범위를 검사하고 전달한다. success를 새로 만들어 상태를 대체하거나, 접수만으로 COMPLETED를 생성하지 않는다. 프로세스 재시작 이후 순번 연속성은 현재 보장하지 않는다.
5. 프로필 인수와 결과 반환
AI-Agent → DeviceAgent: commitVoiceProfile
| 필드 | 타입 | 값·의미 |
|---|---|---|
| method / contractVersion | 각각 String | commitVoiceProfile / voice-profile-v2 |
| requestId / voiceId | 각각 String | 원 등록 요청 / 생성된 화자 키 |
| nickname | String | 표시 이름 |
| locale | String | 등록 언어 메타데이터 |
| personalizationEnabled | Boolean | 개인화 선택 |
| referenceEmbedding | FloatArray | 대표 임베딩. putFloatArray로 전달 |
현재 검증은 voiceId 1~128자, 임베딩 1~4096개/유한수다. 4096은 방어 상한이며 모델 차원 규격이 아니다. DeviceAgent가 모델을 다시 실행하거나 임베딩 값을 변경하지 않는다. 임베딩과 원본 음성을 로그에 출력하지 않는다.
DeviceAgent → AI-Agent: voiceProfileCommitResult
| 필드 | 타입 | 값·의미 |
|---|---|---|
| method / contractVersion | 각각 String | voiceProfileCommitResult / voice-profile-v2 |
| requestId | String | commit과 동일 |
| accepted | Boolean | DeviceAgent 전달 대기 저장 수락 |
| cloudSynced | Boolean | 서버 저장 완료 여부. 시험 우회와 구분 |
| reasonCode | String | 결과 사유 |
현재 callback에는 voiceId가 없다. 같은 requestId의 pending에 대응한다.
| 현재 결과 | accepted | cloudSynced | AI-Agent 해석 |
|---|---|---|---|
| INVALID_PROFILE_PAYLOAD | false | false | 입력 검증 실패 |
| DEVICE_OUTBOX_FAILED | false | false | 기기 전달 대기 저장 실패 |
| WAITING_CLOUD_SYNC | true | false | 기기 인수만 완료. 현재 Service는 DELIVERY_FAILED |
| LOCAL_TEST_ACK | true | true | 로컬 시험용 완료. 운영 서버 저장 증거 아님 |
| callback 없음 | 해당 없음 | 해당 없음 | 10초 후 DEVICE_DELIVERY_TIMEOUT / DELIVERY_FAILED |
제품 연동 시 남은 협의: 서버 저장 ACK를 받은 DeviceAgent가 어떤 시점에 callback을 반환할지 SoC·Backend 담당과 맞춰야 한다. 현재 소스에는 실제 Cloud ACK 전달·늦은 ACK 복구가 완성되어 있지 않다. 기존 10초 대기보다 오래 걸리는 외부 흐름은 AI-Agent 담당과 완료 시점을 별도로 합의해야 하며, 단순히 true를 반환하여 숨기지 않는다.
6. 프로필 관리 결과
관리 결과 공통: method:String, contractVersion:String, requestId:String, success:Boolean, reasonCode:String. DeviceAgent는 검증 후 IoTAgent에 전달하며 등록 App에는 보내지 않는다.
| 결과 | 성공 추가 필드 |
|---|---|
| voiceProfileListResult | profiles:ArrayList<Bundle> |
| voiceProfileDeleteResult | voiceId:String |
profiles 항목: voiceId·nickname은 String, personalizationEnabled는 Boolean이다. 객체 목록은 putParcelableArrayList로 전달한다.
7. 필요 시 개인화 조회
관심사 저장은 APP·Backend 담당 범위다. AI-Agent가 DeviceAgent에 get으로 요청하고 DeviceAgent가 외부 조회 결과를 돌려준다. 등록 App에는 조회 결과를 보내지 않는다.
| 방향 / method | 필드와 타입 |
|---|---|
| AI-Agent → DeviceAgent / getVoicePersonalization | contractVersion:String=voice-personalization-v1, requestId:String, voiceId:String, issuedAtMs:Long, expiresAtMs:Long |
| DeviceAgent → AI-Agent / voicePersonalizationResult | 동일 버전·requestId·voiceId, success:Boolean, reasonCode:String, expiresAtMs:Long |
| 조회 성공 추가 필드 | personalizationEnabled:Boolean, interestCodes:ArrayList<String>, revision:Long ≥ 1 |
AI-Agent의 요청 기한은 생성 후 5초. DeviceAgent 검증 상한은 10초이며 응답에서 기한을 연장하지 않는다. 실패 결과에는 interestCodes를 넣지 않는다. 허용 값은 WEATHER, AIR_QUALITY, HEALTH, SCHEDULE, TRAVEL, FOOD, CULTURE, SPORTS, FINANCE, TECHNOLOGY이며 최대 10개/중복 없음. 개인화 비허용이면 빈 목록이다.
Backend API나 전송 방식은 외부 담당이 정하되 위 결과 타입과 요청 대응은 유지한다. 현재 코드의 외부 중계는 Sample 경로이며 운영 연동 완료를 의미하지 않는다.
8. 결과 예시와 반환값
// DeviceAgent에서 전달 대기 저장 후 AI-Agent에 알리는 예시.
val result = Bundle().apply {
putString("method", "voiceProfileCommitResult")
putString("contractVersion", "voice-profile-v2")
putString("requestId", requestId)
putBoolean("accepted", true)
putBoolean("cloudSynced", false)
putString("reasonCode", "WAITING_CLOUD_SYNC")
}
// 기존 DeviceAgent의 callback 라우터에 CALLER_LLM 대상으로 전달한다.
예시는 Bundle 형식 설명이다. 실제 코드에서는 각 모듈의 Contract 상수를 사용한다. 0은 처리/중계 수락, 음수는 거절이다. 등록·상태·commit에서 호출자 거절은 -2이며, 개인화·관리에서는 -1로 반환될 수 있다. 모든 음수를 하나의 업무 오류로 변환하지 않는다. commit은 실패 반환과 callback이 함께 발생할 수 있다.
9. 구현 요청과 인수 시험
| SoC DeviceAgent 구현 요청 | 확인할 결과 |
|---|---|
| 상수·callback 정책·호출자 검사 연결 | AI-Agent에 요청이 한 번 전달되고 잘못된 caller는 거절 |
| 상태 Bundle 검증·복사 전달 | 등록 App에 같은 requestId·stateSequence·타입 도착 |
| 프로필 대기 저장과 결과 생성 | 저장 실패와 서버 미완료가 성공으로 바뀌지 않음 |
| 관리 결과·개인화 결과 라우팅 | 등록 App으로 일반 관리 결과가 유출되지 않음 |
| 외부 ACK 연동 지점 마련 | 운영 ACK 정책과 10초 제한의 합의·시험 결과 제시 |
공동 시험: 동일 start 재전달, 녹음/생성 중 취소, 취소 후 늦은 결과, 저장 실패, ACK 유실, 전달 재시도, 다른 requestId/voiceId의 조회 응답. 현재 네이티브 추론은 취소 즉시 중단이 아니라 늦은 결과를 버리는 방식이며 프로세스 강제 종료 복구까지 증명한 것은 아니다.
10. 필드별 송신 규칙과 실제 검사
송신 측은 정의된 필드를 정확한 Android 타입으로 보낸다. 기본값이 있다는 이유로 필드를 생략하지 않는다. 다음 표는 현재 코드가 무엇까지 검사하는지도 명시한다. 문서 보강으로 검증 코드가 추가된 것은 아니다.
| 필드·범위 | 송신 규칙 | 현재 검사·누락 시 동작 |
|---|---|---|
| method:String | 해당 요청의 정확한 이름, 필수 | AI-Agent는 알려진 command/callback만 처리. 미등록 method에는 화자인식 오류 callback 보장 없음 |
| contractVersion:String | 메서드별 버전, 필수 | DeviceAgent/AI-Agent 모두 버전 구분. 불일치 시 실행하지 않음 |
| requestId:String, 등록·관리 | 동일 업무 요청에 같은 비어 있지 않은 값 | DeviceAgent 공통 검사는 non-empty, AI-Agent는 non-blank. 모든 경로에 UUID 형식/128자 상한 검사가 있는 것은 아님 |
| requestId / voiceId, 개인화 조회 | [A-Za-z0-9_-]{1,128} | DeviceAgent가 두 필드 형식 검사 |
| voiceId:String, 삭제 | [A-Za-z0-9_-]{1,128} | DeviceAgent 관리 요청에서 검사 |
| voiceId:String, commit | AI-Agent가 생성한 값 그대로 | commit 검사 자체는 non-empty/128자 이하. 관리 요청과 동일한 정규식 검사는 아님 |
| nickname:String, start | trim 후 1~40자, 경로 구분자·제어 문자 제외 | AI-Agent 입력 계약에서 검사. DeviceAgent의 UI 내부 제어 중계는 공통 envelope 중심 |
| locale:String | ko-KR/en-US 명시 | AI-Agent는 빈 값이면 ko-KR. 두 언어 allowlist 검사는 아님 |
| enabled:Boolean | 개인화 버튼 선택값 명시 | 누락 시 AI-Agent getBoolean 기본 true. 누락을 동의로 의도하여 사용하지 않음 |
| issuedAtMs:Long, UI 제어 | 생성 시각 ms | 현재 UI 클라이언트는 포함하지만 AI-Agent의 등록 제어 만료 검사에는 사용하지 않음 |
| stateSequence:Long | 현재 등록의 상태 순번 | DeviceAgent는 실제 Long/0 이상 검사. Int로 보내면 거절 |
| setIndex / setCount / progress:Int | 세트 범위/0~100 | DeviceAgent는 실제 Int와 범위 검사 |
| terminal:Boolean | 종료 여부 명시 | DeviceAgent는 실제 Boolean 검사 |
| referenceEmbedding:FloatArray | 유한한 float 1~4096개 | null·빈 배열·NaN·Infinity·상한 초과 거절 |
| commit의 기타 메타데이터 | 5절 필드 전부 송신, 미설정 발음은 빈 문자열 | DeviceAgent commit 검사가 nickname·locale·동의의 모든 조건을 강제하는 것은 아님 |
현재 모든 Bundle 키를 엄격한 스키마로 검사하는 구조는 아니다. 잘못된 타입을 넣었을 때 모든 경로에서 동일 음수 코드로 정리된다고 가정하면 안 된다. 관리 결과와 commit은 audio, wav, pcm 키를 거절하며, 관리 결과는 referenceEmbedding도 거절한다. 등록 상태를 포함한 모든 method에 동일한 민감 키 차단이 적용된 것은 아니므로 송신 측에서도 금지 데이터를 넣지 않는다.
11. 호출자·대상·즉시 반환값 행렬
아래 숫자는 DeviceAgent의 sendModuleCommand 분기 반환값이다. 반환 0이 실제 callback 수신이나 Service 실행을 보장하지 않는다. AI-Agent 자체 검증 거절은 로그만 남기고 끝날 수 있다.
| 입력 method 그룹 | 허용 송신 앱 | DeviceAgent 처리·callback 대상 | 정상 반환 | 거절 반환 |
|---|---|---|---|---|
| 등록 제어 9종 | 등록 App | Bundle 복사 → AI-Agent(1) | 0 | envelope -1 / 호출자 -2 |
| 원격 start·cancel | IoTAgent | 등록 Activity 실행 | 0 | 입력 -1 / 실행 실패 -3 |
| voiceProfileStateChanged | AI-Agent | 등록 App(8), IoTAgent(2) | 0 | 상태 envelope·타입 -1 / 호출자 -2 |
| commitVoiceProfile | AI-Agent | 대기 저장 후 CommitResult → AI-Agent(1) | accepted이면 0 | 인수 실패 -1 / 호출자 -2 |
| 목록·삭제 관리 요청 | IoTAgent | AI-Agent(1) | 0 | 호출자 또는 payload -1 |
| 관리 결과 | AI-Agent | IoTAgent(2) | 0 | 호출자 또는 payload -1 |
| getVoicePersonalization | AI-Agent | IoTAgent(2) | 0 | 호출자·버전·기한·타입 -1 |
| voicePersonalizationResult | IoTAgent | AI-Agent(1) | 0 | 호출자·버전·기한·타입 -1 |
현재 일반적인 voiceProfileCommitResult 입력을 IoTAgent가 그대로 sendModuleCommand에 넣어 AI-Agent로 전달하는 분기는 없다. 운영 서버 ACK의 수신→pending 대조→AI-Agent callback 연결은 SoC 쪽 추가 구현 지점이다. 상태나 관리 결과 분기에 넣어서 우회하지 않는다.
호출자는 Bundle 안의 caller 문자열이 아니라 Binder UID의 소속 패키지로 검사한다. 시스템 shared UID 환경에서는 이 검사만으로 앱 단위 강한 인증까지 확보했다고 볼 수 없으므로 제품 권한·서명 정책을 함께 적용한다.
12. 관리 결과의 필수 조건과 오류
모든 관리 결과에는 method, contractVersion, requestId, success, reasonCode를 보낸다. 성공의 reasonCode는 빈 문자열이고 실패에는 구체적인 코드가 필요하다. 실패 시 성공 payload가 없을 수 있으므로 success 확인 전 목록·개수 필드를 읽지 않는다.
| 결과 | 성공 payload와 의미 | 실패 코드/조건 |
|---|---|---|
| voiceProfileListResult | profiles:ArrayList<Bundle>. 0개는 정상 빈 목록. DeviceAgent 수락 상한 64개 | PROFILE_LIST_FAILED. 현재 실패에서도 빈 profiles가 붙을 수 있음 |
| voiceProfileDeleteResult | voiceId:String. 요청한 로컬 프로필 삭제 | ENROLLMENT_ACTIVE / PROFILE_NOT_FOUND / PROFILE_DELETE_FAILED |
중첩 목록 필드
| 객체 | 키 | 타입 | 성공 결과의 의미 |
|---|---|---|---|
| profiles[] | voiceId | String | 등록 프로필 키 |
| profiles[] | nickname | String | 표시 이름 |
| profiles[] | personalizationEnabled | Boolean | 프로필 동의값. 이전 프로필 누락값은 false 처리 |
화자 삭제 성공은 서버 계정·관심사까지 원자적으로 삭제했다는 뜻이 아니다.
13. Bundle 왕복 예시
예제는 Android Bundle 구조를 보여준다. 고정 ID는 설명용이며 실제 요청에는 호출자가 생성한 ID를 사용한다. 외부 JSON이나 MQTT 형식은 지정하지 않는다.
프로필 목록 조회와 빈 목록 응답
// DeviceAgent가 AI-Agent callback(1)로 전달할 요청.
val request = Bundle().apply {
putString("method", "listVoiceProfiles")
putString("contractVersion", "voice-profile-v2")
putString("requestId", "list-example-001")
}
// AI-Agent가 DeviceAgent.sendModuleCommand로 보낼 결과.
val response = Bundle().apply {
putString("method", "voiceProfileListResult")
putString("contractVersion", "voice-profile-v2")
putString("requestId", "list-example-001")
putBoolean("success", true)
putString("reasonCode", "")
putParcelableArrayList("profiles", arrayListOf<Bundle>())
}
빈 목록 성공과 연결 실패를 구분한다. 같은 형식으로 실제 목록을 보낼 때 각 Bundle에는 12절 profiles 필드를 넣는다.
관심사 조회 실패 응답
val requestId = "personalization-example-001"
val voiceId = "voice-example-001"
val issuedAtMs = System.currentTimeMillis()
val expiresAtMs = issuedAtMs + 5_000L
val request = Bundle().apply {
putString("method", "getVoicePersonalization")
putString("contractVersion", "voice-personalization-v1")
putString("requestId", requestId)
putString("voiceId", voiceId)
putLong("issuedAtMs", issuedAtMs)
putLong("expiresAtMs", expiresAtMs)
}
val failure = Bundle().apply {
putString("method", "voicePersonalizationResult")
putString("contractVersion", "voice-personalization-v1")
putString("requestId", requestId)
putString("voiceId", voiceId)
putBoolean("success", false)
putString("reasonCode", "BACKEND_UNAVAILABLE")
putLong("expiresAtMs", expiresAtMs)
// 실패에는 interestCodes를 넣지 않는다. 만료 시각도 연장하지 않는다.
}
성공 응답에서는 success=true, reasonCode="", personalizationEnabled:Boolean, revision:Long, putStringArrayList("interestCodes", arrayListOf("WEATHER"))를 사용한다. requestId·voiceId·기한은 원 요청 값 그대로다.
14. callback 순서와 재시도 한계
등록 제어 → AI-Agent 상태 callback → DeviceAgent → 등록 App
프로필 생성 → commitVoiceProfile → DeviceAgent 대기 저장
→ voiceProfileCommitResult → AI-Agent 최종/실패 상태
→ voiceProfileStateChanged → DeviceAgent → 등록 App
- callback이 송신 함수의 반환보다 먼저 도착할 수 있다. pending과 타이머를 전송 전에 준비한다.
- AI-Agent는 commit 전 10초 타이머를 등록하며 callback 수신 시 해제한다. 인수 수락과 서버 완료는 accepted/cloudSynced로 분리한다.
- 전달 재시도는 같은 pending의 requestId·voiceId를 사용한다. 새 프로필을 만들지 않는다.
- 현재 commit 결과에는 재시도 회차 값이 없다. 같은 pending의 늦은 ACK는 DELIVERY_FAILED 이후에도 반영될 수 있다. 오래된 회차와 새 회차를 완벽히 구분하는 보장은 없다.
- 다른 requestId, pending 없음, 종료된 세션의 commit 결과는 AI-Agent에서 무시한다. 프로세스 재시작 후 pending 복원은 현재 범위가 아니다.
- 상태 순번은 publish 때 증가하지만 현재 상태 재전송은 같은 순번일 수 있다. 중복 상태를 새 실행으로 처리하지 않는다.
- 개인화 결과는 원 요청의 requestId·voiceId·기한과 대조한다. 등록 ACK와 다른 조회 계약이다.
15. 인터페이스 인수 시험표
| ID | 입력/순서 | 기대 결과 | 확인 위치 |
|---|---|---|---|
| AI-01 | 유효 listVoiceProfiles | 같은 requestId의 ListResult, 빈 목록도 success=true 가능 | AI-Agent → DeviceAgent → 외부 경계 |
| AI-02 | 등록 상태 stateSequence를 Int로 전송 | DeviceAgent -1, 등록 App에 전달하지 않음 | DeviceAgent 검증 |
| AI-03 | commit 임베딩에 NaN 포함 | accepted=false/cloudSynced=false, INVALID_PROFILE_PAYLOAD 및 -1 | DeviceAgent 반환+callback |
| AI-04 | 기기 저장 성공, 실제 서버 ACK 없음 | WAITING_CLOUD_SYNC. 완료로 바꾸지 않음 | CommitResult·AI-Agent 상태 |
| AI-05 | commit callback 유실 | 10초 후 DELIVERY_FAILED/DEVICE_DELIVERY_TIMEOUT | AI-Agent 상태 |
| AI-06 | 취소한 요청에 늦은 commit 결과 | 현재 세션 재개·완료 없음 | AI-Agent·등록 App |
| AI-07 | 같은 pending 전달 재시도 후 이전 회차 ACK | 현재 구현에서 구분 불가 가능성을 확인·운영 정책 합의 | SoC/Service 공동 |
| AI-08 | 개인화 실패에 interestCodes 첨부 | DeviceAgent -1. AI-Agent로 중계하지 않음 | DeviceAgent 검증 |
| AI-09 | 개인화 응답의 voiceId/requestId 변경 | 현재 조회 결과로 사용하지 않음 | AI-Agent 조회 추적 |
| AI-10 | 관리 결과에 referenceEmbedding 첨부 | DeviceAgent -1, 외부 목록 결과로 전달하지 않음 | DeviceAgent 검증 |
이 표는 시험 요구사항이며 실기기 합격 기록이 아니다. 요청 ID·method·타입·반환값·callback 순서를 남기되 음성·임베딩 원문은 로그에서 제외한다.
16. REQUEST / RESPONSE 값 예시
예시는 실제 Binder로 주고받는 Android Bundle을 Kotlin으로 구성한 코드다. 각 코드 블록은 독립된 예시이며 import android.os.Bundle을 사용한다. request는 요청 Bundle, callback은 상대편에서 구성해 돌려주는 Bundle이다. JSON 직렬화나 request/callback이라는 포장 필드는 전송하지 않는다. 필드명을 확인하기 쉽게 문자열로 표시했으며 구현에서는 기존 Contract 상수를 사용한다. 식별자·닉네임·시각은 설명용 값이다.
| 값 종류 | Bundle에 넣는 방법 |
|---|---|
| method, contractVersion, requestId, voiceId 등 문자열 | putString |
| success, terminal, enabled 등 참/거짓 | putBoolean |
| setIndex, setCount, progress | putInt |
| stateSequence, issuedAtMs, expiresAtMs, revision | putLong |
| profiles | putParcelableArrayList: 내부 항목 각각 Bundle |
| interestCodes | putStringArrayList |
| referenceEmbedding | putFloatArray |
sendModuleCommand()의 동기 반환 0은 해당 호출의 수락/중계를 뜻한다. 아래 callback과 별개이며 최종 성공을 뜻하지 않는다. callback이 동기 반환보다 먼저 도착할 수도 있다. 허용되지 않은 caller나 잘못된 필드는 중계 단계에서 거절될 수 있으므로, 모든 요청에 반드시 callback이 온다고 가정하지 않는다.
16.1 프로필 전달: 기기 저장과 서버 완료 구분
AI-Agent → DeviceAgent 요청, DeviceAgent → AI-Agent callback이다. 아래 2차원 벡터는 Bundle 구조 설명용이며 실제 모델 임베딩이나 등록 시험 데이터가 아니다. 실제로는 모델이 생성한 FloatArray 전체를 보낸다.
// REQUEST: 요청 송신 측에서 구성
val request = Bundle().apply {
putString("method", "commitVoiceProfile")
putString("contractVersion", "voice-profile-v2")
putString("requestId", "enroll-001")
putString("voiceId", "vp_demo_01")
putString("nickname", "Niky")
putString("locale", "ko-KR")
putBoolean("personalizationEnabled", true)
putFloatArray("referenceEmbedding", floatArrayOf(0.6f, 0.8f))
}
// RESPONSE: 상대편에서 구성하여 별도 callback으로 전달
val callback = Bundle().apply {
putString("method", "voiceProfileCommitResult")
putString("contractVersion", "voice-profile-v2")
putString("requestId", "enroll-001")
putBoolean("accepted", true)
putBoolean("cloudSynced", false)
putString("reasonCode", "WAITING_CLOUD_SYNC")
}
이 경우 기기는 인수했지만 서버 저장은 확인되지 않았다. AI-Agent는 DELIVERY_FAILED로 처리하며 등록 완료를 표시하지 않는다. callback에 voiceId를 추가해서 매칭하지 않고 pending requestId로 연결한다.
| 상황 | accepted | cloudSynced | reasonCode | AI-Agent 처리 |
|---|---|---|---|---|
| 기기 대기 저장 실패 | false | false | DEVICE_OUTBOX_FAILED | 전달 실패 |
| 기기 저장만 성공 | true | false | WAITING_CLOUD_SYNC | 전달 실패/재시도 가능 |
| 로컬 시험 ACK 활성화 | true | true | LOCAL_TEST_ACK | COMPLETED. 실제 서버 저장 증거 아님 |
| 10초 동안 ACK 없음 | 응답 없음 | 응답 없음 | AI-Agent가 DEVICE_DELIVERY_TIMEOUT 생성 | DELIVERY_FAILED |
현재 실제 서버 ACK를 IoTAgent에서 DeviceAgent로 연결하는 경로는 별도 구현 대상이다. 위 로컬 시험 결과로 서버 연동 완료를 판단하지 않는다.
16.2 화자 목록 조회
DeviceAgent → AI-Agent 요청, AI-Agent → DeviceAgent 결과다. DeviceAgent는 외부 요청 경로로 결과를 돌려보내며 등록 App의 상태 callback으로 보내지 않는다.
// REQUEST: 요청 송신 측에서 구성
val request = Bundle().apply {
putString("method", "listVoiceProfiles")
putString("contractVersion", "voice-profile-v2")
putString("requestId", "list-001")
}
// RESPONSE: 상대편에서 구성하여 별도 callback으로 전달
val callback = Bundle().apply {
putString("method", "voiceProfileListResult")
putString("contractVersion", "voice-profile-v2")
putString("requestId", "list-001")
putBoolean("success", true)
putString("reasonCode", "")
putParcelableArrayList<Bundle>("profiles", arrayListOf(
Bundle().apply {
putString("voiceId", "vp_demo_01")
putString("nickname", "Niky")
putBoolean("personalizationEnabled", true)
}
))
}
등록된 화자가 없으면 success=true, profiles=[]다. 조회 실패와 빈 목록을 구분한다. 임베딩·원본 음성은 목록에 포함하지 않는다.
16.3 화자 삭제 실패
// REQUEST: 요청 송신 측에서 구성
val request = Bundle().apply {
putString("method", "deleteVoiceProfile")
putString("contractVersion", "voice-profile-v2")
putString("requestId", "delete-001")
putString("voiceId", "vp_missing")
}
// RESPONSE: 상대편에서 구성하여 별도 callback으로 전달
val callback = Bundle().apply {
putString("method", "voiceProfileDeleteResult")
putString("contractVersion", "voice-profile-v2")
putString("requestId", "delete-001")
putBoolean("success", false)
putString("reasonCode", "PROFILE_NOT_FOUND")
putString("voiceId", "vp_missing")
}
삭제 성공은 같은 결과 method에서 success=true, reasonCode=""이다. 이 결과는 기기 프로필 삭제이며 서버 계정 삭제 완료를 뜻하지 않는다.
16.4 서버 개인화 조회: 성공과 실패
AI-Agent → DeviceAgent로 조회를 요청하고, DeviceAgent가 외부 조회 결과를 AI-Agent로 돌려준다. 관심사 수정은 APP/서버 소관이며 AI-Agent가 로컬 DB를 원본으로 관리하지 않는다. 아래 시각은 예시다. 실제 요청은 현재 시각과 5초 뒤 기한을 넣고, 응답은 원 요청의 기한을 그대로 유지한다.
// REQUEST: 요청 송신 측에서 구성
val request = Bundle().apply {
putString("method", "getVoicePersonalization")
putString("contractVersion", "voice-personalization-v1")
putString("requestId", "personal-001")
putString("voiceId", "vp_demo_01")
putLong("issuedAtMs", 1790000000000L)
putLong("expiresAtMs", 1790000005000L)
}
// RESPONSE: 상대편에서 구성하여 별도 callback으로 전달
val callback = Bundle().apply {
putString("method", "voicePersonalizationResult")
putString("contractVersion", "voice-personalization-v1")
putString("requestId", "personal-001")
putString("voiceId", "vp_demo_01")
putBoolean("success", true)
putString("reasonCode", "")
putLong("expiresAtMs", 1790000005000L)
putBoolean("personalizationEnabled", true)
putStringArrayList("interestCodes", arrayListOf("WEATHER", "HEALTH"))
putLong("revision", 3L)
}
// REQUEST: 요청 송신 측에서 구성
val request = Bundle().apply {
putString("method", "getVoicePersonalization")
putString("contractVersion", "voice-personalization-v1")
putString("requestId", "personal-002")
putString("voiceId", "vp_demo_01")
putLong("issuedAtMs", 1790000010000L)
putLong("expiresAtMs", 1790000015000L)
}
// RESPONSE: 상대편에서 구성하여 별도 callback으로 전달
val callback = Bundle().apply {
putString("method", "voicePersonalizationResult")
putString("contractVersion", "voice-personalization-v1")
putString("requestId", "personal-002")
putString("voiceId", "vp_demo_01")
putBoolean("success", false)
putString("reasonCode", "BACKEND_UNAVAILABLE")
putLong("expiresAtMs", 1790000015000L)
}
실패 응답에 interestCodes를 넣지 않는다. 성공이지만 개인화가 꺼져 있으면 personalizationEnabled=false, interestCodes=[]로 구분한다. 만료되거나 ID가 다른 결과는 현재 조회에 적용하지 않는다.
Binder에서 주고받는 위치
| 방향 | 송신 / 전달 지점 | 수신 지점 |
|---|---|---|
| 등록 App → DeviceAgent | Proxy의 sendModuleCommand(input, output) | DeviceAgent의 명령 분기 |
| AI-Agent → DeviceAgent | sendModuleCommand(input, output) | DeviceAgent의 명령 분기 |
| DeviceAgent → AI-Agent | onModuleCallbackLoop(CALLER_LLM=1, Bundle(input)) | AI-Agent의 onModuleCallback |
| DeviceAgent → 등록 App | onModuleCallbackLoop(CALLER_VOICE_PROFILE_REGISTRATION=8, Bundle(state)) | 등록 App의 onModuleCallback |
DeviceAgent는 요청/결과 method에 맞는 수신자에게 전달한다. 등록 상태는 등록 App과 IoTAgent로 전달하지만, 개인화 조회 결과를 등록 App에 전달하지 않는다. 표의 onModuleCallbackLoop는 DeviceAgent 내부 함수이며 등록 App에서 호출하는 API가 아니다.
등록 App의 실제 전송 형태는 다음과 같다. request에는 아래 각 사례의 요청 Bundle을 넣는다.
// 등록 App: DeviceControlProxy가 연결된 상태에서 실행
val service = DeviceControlProxy.getService()
if (service != null) {
val output = Bundle()
val returnCode = service.sendModuleCommand(request, output)
val acceptedForRouting = returnCode >= 0
// acceptedForRouting은 최종 등록 성공이 아니다.
// 최종 상태는 아래의 별도 onModuleCallback으로 받는다.
}
다음은 등록 App callback에서 실제 타입으로 필드를 읽는 부분이다. 기존 callback 구현 안에서 사용하며, 상태 화면 적용 시 현재 requestId와 stateSequence를 대조한다.
override fun onModuleCallback(outdata: Bundle?) {
val state = outdata ?: return
if (state.getString("method") != "voiceProfileStateChanged") return
if (state.getString("contractVersion") != "voice-profile-v2") return
val requestId = state.getString("requestId") ?: return
val stage = state.getString("state") ?: return
val sequence = state.getLong("stateSequence", -1L)
val setIndex = state.getInt("setIndex", 1)
val progress = state.getInt("progress", 0)
val terminal = state.getBoolean("terminal", false)
val reasonCode = state.getString("reasonCode").orEmpty()
// 추출 이후 기존 상태 reducer에 전달한다.
}
위 수신 코드는 등록 상태용이다. 개인화 결과는 AI-Agent에서 getStringArrayList("interestCodes"), getLong("revision"), getBoolean("success") 등 해당 결과 계약의 필드로 읽는다. 생성 예시의 callback을 요청자가 자기 자신에게 보내는 구조가 아니다.
Connections
- 소스 위치: SoC
DeviceAgent.java,voiceprofile/VoiceProfileContract.java,ModuleCmdCallbackPolicy.java; AI-AgentForegroundService.kt,VoiceProfileIpcContract,VoiceProfileWorkGuard.kt. - 기존 AI-Agent 상세 자료: 이전 Sample 설명 포함. 이 요청서와 겹치는 구현 기준은 본 페이지를 먼저 본다.
- AI-Agent 내부 구조: 모델·녹음 내부 참고. DeviceAgent 구현 요청 범위 아님.