발화가 어려운 사람의 '입'이 되는 AAC 앱. 주변 대화를 듣고, 사용자가 탭 한 번으로 말할 완성 문장 후보 4개를 만듭니다.
🔗 배포 https://gaehwa.vercel.app · 📋 심사위원 참고용 부록 ← 3분 안에 직접 확인하는 법
⚠️ 배포 URL은 AI 서버(GPU 기기)가 켜져 있을 때만 답변이 나옵니다 — Vercel엔 화면만 있습니다(이유).
바로 가기 — 프로젝트 소개 · 주요 기능 · 사용 기술 · 이 프로젝트가 푼 문제 · 실행 · 확인 · 정직한 한계 · 문서 · 팀
병원에 간 후두적출 환자가 있습니다. 간호사가 묻습니다 — "어디가 불편해서 오셨어요?" 머릿속엔 답이 있는데 입이 안 움직입니다. 종이에 쓰려면 1분, 그 사이 간호사는 다른 일을 하러 갑니다.
개화는 그 1분을 3초로 만듭니다.
간호사: "어디가 불편해서 오셨어요?" ← 앱이 듣고
↓
[답변] 허리가 아파서 왔어요 ← 후보 4개를 띄우면
[답변] 사흘째 허리가 아파요
[보류] 음… 잠시만요
[추가] 검사도 해야 하나요?
↓
탭 한 번 ← 앱이 소리 내어 말합니다
| 기존 AAC | 개화 | |
|---|---|---|
| 문장 | 사용자가 미리 등록해두고 찾아서 누름 | 앱이 지금 이 대화를 듣고 만듦 |
| 상황 | 사용자가 판단 | 앱이 파악해서 보여줌 |
| 말투 | 앱이 정한 문어체 | 내 말투 예시를 학습해 따라함 |
| 원칙 | 구현 |
|---|---|
| 🔒 대화가 기기 밖으로 나가지 않는다 | 외부 API 0개. STT·LLM·화자분리 전부 로컬 |
| 🖐️ AI가 마음대로 말하지 않는다 | 사용자가 탭하기 전엔 절대 발화하지 않음. 호흡 신호(「음… 잠시만요」)만 예외 |
| 🚫 거절할 권리 | 후보 4개 안에는 항상 거절하거나 보류할 수 있는 것이 있음 (코드로 강제) |
상대 발화를 듣고 행위를 분류한 뒤(질문/제안/안내), 그 행위에 유효한 응답만 만듭니다.
"드시는 약 있으세요?" → [답변] [조건] [보류] [추가] ← 거절 자리가 없음
"적립 도와드릴까요?" → [수락] [거절] [조건] [질문] ← 거절이 유효한 자리
이 설계가 이 프로젝트의 핵심입니다. 왜 이렇게 했는지는 아래 「이 프로젝트가 푼 문제」를 보세요.
목소리 지문(임베딩)으로 화자 A~F를 자동 구분합니다. 답할 대상을 골라 그 사람에게만 답할 수 있습니다.
- 신뢰도 3단계:
confirmed(확실) /estimated(추정) /unknown(미확인) - 목소리 지문은 저장하지 않습니다 — 대화가 끝나면 사라집니다
말이 언제 끝나는지 AI가 추측하지 않습니다. 사용자가 버튼을 쥔 동안만 듣습니다.
문제: 상대가 말을 시작한 뒤 버튼을 누르면 앞부분을 놓칩니다. 해결: 링버퍼가 항상 최근 3초를 기억합니다. 버튼을 누르면 3초 전부터 소급합니다. (블랙박스와 같은 원리)
병원·은행처럼 실수가 부담스러운 대화는 목적·말할 것·물어볼 것을 미리 적어둡니다. 후보가 그 방향으로만 나오고, 전달된 항목은 자동으로 체크됩니다.
앱이 방금 말한 문장을 마이크가 다시 듣고 "상대가 말했다"고 착각하는 것을 막습니다. TTS 중 STT 정지 + 자기 발화 유사도 필터.
GPU OOM·CPU 폴백·마이크 권한 실패가 조용히 일어나면 "그냥 안 되는" 상태가 됩니다.
/api/version이 엔진이 GPU/CPU 중 무엇으로 떴는지 노출해 무엇이 막혔는지 보이게 합니다.
상대방 목소리
│
▼
┌─────────────────┐
│ ① 음성 → 글자 │ faster-whisper large-v3-turbo GPU (int8_float16)
└────────┬────────┘
├──────────────┐
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ ② 목소리 지문 │ │ ③ 답변 생성 │ SKT A.X 4.0 Light 7B Q4
│ eres2net CPU │ │ ollama, GPU 상주 │
└─────────────────┘ └──────────────────┘
| 역할 | 모델 | 장치 | 라이선스 | 선정 근거 |
|---|---|---|---|---|
| 음성 인식 | faster-whisper large-v3-turbo | GPU (int8_float16, beam=1) | MIT | 한국어 정확도 · 0.2~0.4초 실측 |
| 답변 생성 | SKT A.X 4.0 Light 7B Q4_K_M | GPU 상주 (keep_alive=-1) |
Apache-2.0 | 골든셋 97.4% vs kanana-8B 52.8% — 큰 모델이 항상 낫지 않았다 |
| 화자 지문 | eres2net (3D-Speaker, ONNX) | CPU | Apache-2.0 | 3파전 실측 — 분리 간격 2.2배로 선정 |
외부 API를 하나도 쓰지 않습니다. AAC 사용자의 대화는 병명·복용약·재정 같은 가장 사적인 정보이기 때문입니다.
| 언어 | Python 3.12 |
| 웹 | FastAPI 0.136.3 + Uvicorn 0.48.0 |
| 계약 | Pydantic — API 계약을 코드로 (app/contract.py) |
| 오디오 | ffmpeg (webm/opus → wav 16kHz mono) |
| 스트리밍 | NDJSON — type 기반 분기. 실제 순서는 candidate×N → situation → done (첫 후보를 앞당기려 SITUATION을 뒤로 뺐다). done만 마지막이 보장된다 |
전체 목록과 라이선스 의무: LICENSES/REGISTER.md
app/
├── policy/ 사람이 바꾸는 값. 코드 수정 없이 변경.
│ ├── policy.yaml ★ 유일한 정본. 모든 숫자에 실측 근거 주석
│ └── prompts/
│
├── engines/ 외부 자원 (whisper·eres2net·ollama·ffmpeg)
│ 인터페이스 뒤 → 테스트에서 스텁 가능
│
├── core/ 순수 로직. I/O 없음 → 0.7초 테스트
│ ├── acts.py ★ 인접쌍 — 이 프로젝트의 핵심
│ ├── prompt.py · parse.py · filters.py · speaker_policy.py
│
├── pipeline/ 조립 (엔진 + 코어)
└── contract.py Pydantic = API 계약의 코드화
왜 정책과 코드를 나눴나: 감사에서 라벨 어휘가 3곳(서버/프론트/채점기), 임계값이 코드 곳곳에 사본으로 흩어져 있었습니다. "한 곳 고치면 나머지가 남는" 상태였고, 실제로 채점기가 사본을 들고 있어 서버가 통과시킨 정답을 오판했습니다. 지금은
policy.yaml이 유일한 정본이고/api/policy로 프론트에 내려갑니다.
사용자 프로필: "김OO, 65세, 후두적출. 혈압약 복용 중"
약사: "드시는 약 있으세요?"
앱이 제시한 후보:
[확인] 약은 잘 챙겨 먹고 있어요 ← 정답
[거절] 저는 약 안 먹어요 ← ⚠️ 프로필과 정반대
측정: 후보의 28%(5/18)에 이런 모순이 있었습니다.
AAC 사용자는 이 버튼을 탭합니다. 약국에서 복약 사실을 부정하는 버튼이 제공되고 있었습니다. 품질 문제가 아니라 안전 문제입니다. 그리고 골든셋 98%는 이걸 한 번도 못 잡았습니다.
세 제약이 충돌했습니다:
- "라벨이 서로 달라야 한다" (형식 — 검증 가능)
- "최소 2개는 답변" (의미)
- "1개는 거절 계열" (의미)
7B는 검증 가능한 제약을 지키고 의미를 버립니다. 그리고 거절 슬롯에 넣을 재료가 없으면 지어냅니다. 즉 *"거절할 권리를 주자"*는 좋은 의도가 거짓말 생산기를 만들었습니다.
더 나빴던 것: 코드가 프롬프트를 배신하고 있었습니다.
if key in state.seen_texts or cand["label"] in state.seen_labels: # 라벨 중복 제거프롬프트는 "답변 2개"를 요구하는데 이 줄이 두 번째 답변을 버렸습니다.
- Schegloff & Sacks (1973) — conditional relevance: 질문의 응답 자리에는 답이 온다
- Pomerantz (1984) — preference organization: 거절은 제안에 대한 유효한 응답이다. 질문에 대한 응답이 아니다
→ 사실 질문에서 거절권의 올바른 형태는 **「말씀드리고 싶지 않아요」(보류)**이지 **「약 안 먹어요」(거짓)**가 아닙니다.
거절권을 삭제하지 않고 행위별로 재정의했습니다:
app/policy/policy.yaml:
acts:
plans:
WH_QUESTION: [답변, 답변, 보류, 추가, 확인, 의견] # 거절 없음
YN_QUESTION: [답변, 조건, 보류, 추가, 확인, 의견] # 답변 1개 ★
OFFER: [수락, 거절, 조건, 질문, 확인, 보류] # 거절이 유효한 유일한 자리
INFORM: [확인, 의견, 질문, 보류, 추가, 조율]★ YN 질문의 답변 슬롯이 1개인 이유: 예/아니오 질문의 참인 답은 하나입니다. 슬롯을 2개 주면 모델이 네/아니요 쌍으로 채워 두 번째가 필연적으로 사실을 뒤집습니다.
| 지표 | 이전 | 이후 |
|---|---|---|
| 프로필 모순(거짓말) | 28% (5/18) | 0% (0/18) |
| 거절권 보장 | 1/3 누락 | 10/10 |
| 후보 개수 | 2.8~4.0 | 4.00 |
| 상대 질문 되묻기 | 5~10% | 0% |
| 행위 분류 정확도 | — | 16/16 |
| 첫 후보 지연 | — | 중앙값 246ms (8회, 205~446ms) |
| 방법 | 배제 사유 (실측) |
|---|---|
| JSON schema 강제 | 첫 토큰 116ms → 1,930ms (16배). 제품 지표 파괴 |
| Cross-encoder 재순위 | 여유 VRAM 1,230MiB < 필요 1.6GB. 스트리밍과도 상충 |
| 7B 파인튜닝 | 선행연구가 동급 7B로 실패 (arXiv 2509.13671, 수용률 2.3%) |
| 화행 분류기 모델 | 정규식으로 16/16. 3i4k는 CC-BY-NC(상업 불가). AAC 화행 SOTA 38.6% |
| Python | 3.12 |
| GPU | NVIDIA, VRAM 8GB 이상 (CUDA) |
| 시스템 | ffmpeg, ollama |
pip install -r requirements.txt # 버전 고정됨
./pull_models.sh # ollama + A.X 4.0 Light 내려받기
python3 server.py # HTTP 8737 (+ 인증서 있으면 HTTPS 8443)models/eres2net.onnx는 sherpa-onnx 배포본에서 받습니다
(라이선스 원본 배포처 존중 — 저장소에 포함하지 않음).
⚠️ 마이크는 HTTPS(secure context)에서만 동작합니다.getUserMedia의 브라우저 정책입니다.
curl -s localhost:8737/api/version{"code":"9b7cb43b","started":"2026-07-16 05:04:39",
"device":{"llm":"gpu:A.X-4.0-Light-gguf:Q4_K_","asr":"cuda","speaker":"cpu"}}
asr이cpu면 조용한 CPU 폴백입니다(0.3초 → 5초). 이걸 눈에 보이게 하려고 노출합니다.
curl -s -X POST localhost:8737/api/candidates -H 'Content-Type: application/json' -d '{
"mode":"reply",
"profile":{"basic":"김OO, 65세, 후두적출. 혈압약 복용 중","style_examples":["부탁드립니다"]},
"turns":[{"role":"partner","text":"드시는 약 있으세요?"}]
}'{"type":"candidate","index":0,"label":"답변","text":"혈압약 복용 중이에요."}
{"type":"candidate","index":1,"label":"조건","text":"약은 꼭 챙겨 먹어야 해요"}
{"type":"candidate","index":2,"label":"보류","text":"음… 잠깐 생각해 볼게요"}
{"type":"candidate","index":3,"label":"추가","text":"혹시 부작용은 없나요?"}
{"type":"situation","summary":"약국에서 복용약 확인 중","tags":["약국","복약"],...}
{"type":"done","count":4,"first_ms":307,"act":"YN_QUESTION",...}
act가YN_QUESTION이므로 거절 슬롯이 없고, 답변이 프로필과 일치합니다.
./tests/run_all.sh # 단위 — 서버·GPU 불필요, 0.7초
python3 eval/run_eval.py 3 # E2E — 골든셋 76 시나리오 × 3회 (~8분, 서버 필요)| 단위 | E2E | |
|---|---|---|
| 잡는 것 | 설계 불변식(거절권·인접쌍), 필터 로직 | 실제 생성 품질, ★프로필 모순 |
| 시간 | 0.7초 | ~8분 |
이 README의 모든 점수는 "우리가 만든 골든셋(76 시나리오)에서의 성능"이지 "제품 성능"이 아닙니다.
프롬프트에 정답(「예순다섯입니다」)이 박혀 있고 골든셋이 must_any: ["65","예순"]으로 바로 그 문자열을 검사했습니다.
eval이 프롬프트 복사 능력을 재고 있었습니다. 이것이 "eval 통과인데 실사용 깨짐"의 정체였고, 오염을 걷어낸 뒤의 숫자가 지금 것입니다.
프로필: "김OO, 65세, 후두적출. 혈압약 복용 중" ← 수술 날짜 없음
상대: "수술은 언제 하셨어요?"
출력: "1년 전에 했어요" ← 창작 (실측 1/3)
7B의 한계이며 구조로 못 막았습니다. 골든셋의 환각-없는정보(안전) 시나리오가 회귀를 감시합니다.
이 결함들은 단위·E2E·화면 흐름 테스트를 전부 통과했습니다:
| 결함 | 왜 못 잡았나 |
|---|---|
| GPU OOM | HTTPS를 별도 프로세스로 띄워 whisper가 2번 로드. 단위 58종·E2E 98.5% 통과 중이었다 |
| 다중화자 죽음 | 상태 통합 때 참조가 끊김. 화면은 렌더되고 문법도 맞다 |
| 프리롤 0 | AudioContext가 잠들어 있음. 브라우저 동작이라 서버 테스트로 불가능 |
→ 마이크를 실제로 누르는 경로만이 검출할 수 있었습니다.
| 검증함 | 검증 못 함 |
|---|---|
| zeroth-korean (화자 임계값) | 고령자 음성 STT 정확도 ← 최대 구멍 |
| 합성 2화자 (분리 경로) | 실제 다자 대화, 중첩 발화, 실제 AAC 사용자 발화 |
| 문서 | 대상 · 내용 |
|---|---|
| 심사위원 참고용 부록 | 3분 안에 직접 확인하는 법. 주장마다 "이렇게 확인하십시오"를 붙였습니다 |
| 사용 기술 딥다이브 | 4층 구조 — 초등학생 2분 → 비전공자 8분 → 개발자 20분 → 연구자 30분 |
| API 명세서 | 946줄. 모든 예시가 실측 응답 |
| 추가기획서 | 인접쌍 재설계 + 근거 레지스터 14건 |
| 라이선스 레지스터 | 구성요소 × 라이선스 × 의무 |
목업이 아니라 실제 앱을 렌더한 것입니다.
| 실시간 대화 | 다중 화자 | 대화 준비 |
|---|---|---|
![]() |
![]() |
![]() |
| 상대 말을 듣고 후보 4개 제시 | 화자 A/B 구분 · 답할 대상 선택 | 목적·할 말을 미리 등록 |
| 홈 | 대화 종료 | 내 정보 |
|---|---|---|
![]() |
![]() |
![]() |
| 준비한 대화 · 자주 쓰는 표현 | 달성률 확인 · 저장/삭제 선택 | 프로필 · 듣기 · 화자 임계값 |
| 역할 | 팀원 |
|---|---|
| AI 모델링 & 백엔드 | 안지홍 · 이윤지 |
| 프론트엔드 | 이소연 · 김현성 |
이 저장소는 AI 모델링 & 백엔드 산출물입니다. 프론트엔드는 별도 저장소에 있습니다.
| 코드 | MIT — LICENSE |
| 사용 모델·라이브러리 | LICENSES/REGISTER.md (구성요소×라이선스×의무 표) |
| 앱 고지문 | LICENSES/NOTICE |
사용 모델은 전부 Apache-2.0 / MIT입니다.
상업 이용이 제한되는 모델(EXAONE · 3i4k 화행분류기 · jina-reranker · Supertonic)은 검토 후 배제했고,
사유를 REGISTER.md §4에 남겼습니다.
어떤 모델도 파인튜닝하지 않습니다 → 학습 데이터 라이선스 의무가 발생하지 않습니다.
프론트엔드는 별도 저장소입니다. 이 저장소는 AI 백엔드만 담고 있습니다.









