Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ S3_REGION=us-east-1
LLM_API_KEY=
LLM_BASE_URL=https://factchat-cloud.mindlogic.ai/v1/gateway
LLM_PRO_MODEL=gemini-3.1-pro-preview
LLM_PRO_TIMEOUT_SEC=30.0
LLM_PRO_TIMEOUT_SEC=60.0
LLM_FLASH_MODEL=gemini-3.5-flash-lite
LLM_FLASH_TEMPERATURE=0.4
LLM_FLASH_MAX_TOKENS=512
Expand All @@ -46,7 +46,7 @@ STT_PROVIDER=auto
DEEPGRAM_API_KEY=
DEEPGRAM_MODEL=whisper-large
DEEPGRAM_LANGUAGE=ko
DEEPGRAM_TIMEOUT_SEC=30
DEEPGRAM_TIMEOUT_SEC=20
DEEPGRAM_CONNECT_TIMEOUT_SEC=5
STT_MAX_ATTEMPTS=3
STT_RETRY_BACKOFF_SEC=0.5
Expand Down
4 changes: 2 additions & 2 deletions ai/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ LLM_API_KEY=
LLM_BASE_URL=https://factchat-cloud.mindlogic.ai/v1/gateway
LLM_PRO_MODEL=gemini-3.1-pro-preview
LLM_PRO_TEMPERATURE=0.2
LLM_PRO_TIMEOUT_SEC=30.0
LLM_PRO_TIMEOUT_SEC=60.0
LLM_FLASH_MODEL=gemini-3.5-flash-lite
LLM_FLASH_TEMPERATURE=0.4
LLM_FLASH_MAX_TOKENS=512
Expand Down Expand Up @@ -73,7 +73,7 @@ DEEPGRAM_API_KEY=
DEEPGRAM_BASE_URL=https://api.deepgram.com/v1
DEEPGRAM_MODEL=whisper-large
DEEPGRAM_LANGUAGE=ko
DEEPGRAM_TIMEOUT_SEC=30 # read 한도. 정상 호출 p50 3.6초/최대 12초
DEEPGRAM_TIMEOUT_SEC=20 # read 한도. 정상 호출 p50 3.8초/p90 8.1초/최대 12.1초
DEEPGRAM_CONNECT_TIMEOUT_SEC=5 # connect 한도. 일괄이면 연결 막힘도 read 한도만큼 대기
STT_MAX_ATTEMPTS=3 # 일시 장애(retriable) 재시도 횟수. 1=재시도 없음
STT_RETRY_BACKOFF_SEC=0.5 # 지수 백오프 기준값 (0.5 → 0.5s, 1s)
Expand Down
8 changes: 5 additions & 3 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ chain = prompt | llm | PydanticOutputParser(pydantic_object=...)
- **TTS: Gemini TTS 기본(한국어)** (`voice/tts/`) — 질문(INTERVIEWER) 메시지 음성화. Deepgram/OpenAI TTS 는 한국어 미지원이라 `GeminiTtsProvider`(`gemini-2.5-flash-preview-tts`, voice=Kore)가 기본. Gemini 는 raw PCM(L16/24kHz)을 반환하므로 WAV 로 감싸 `audio/wav` 로 저장. `TtsProvider` 추상화 + `GatewayTtsProvider`(Mindlogic 게이트웨이 `/audio/speech`, LLM_API_KEY, raw PCM→WAV)/`GeminiTtsProvider`(직접 GEMINI_API_KEY)/`MockTtsProvider`, `build_tts_provider` factory(`TTS_PROVIDER=auto`면 **LLM_API_KEY(gateway) > GEMINI_API_KEY** 순 — 게이트웨이 우선으로 직접 키 429 부하 분산). gateway/gemini 는 `gemini_tts_model`/`gemini_tts_voice` 공유. `generate.tts` consumer 가 합성 → S3 PUT(확장자는 content_type 기준) → `callback.tts` 발행. 재생은 Core 오디오 프록시(`GET /api/sessions/{sid}/messages/{mid}/audio`) 경유(MinIO presigned URL 이 내부 호스트라 브라우저 직접 접근 불가).
- **스트리밍 STT (실시간 음성 답변, RT3): Deepgram Live** (`voice/stt/deepgram_live.py`) — `websockets`로 Deepgram WS(`wss://api.deepgram.com/v1/listen`, nova-2)에 연결, interim/final 자막을 실시간 반환. `voice/stt/live.py`(`LiveSttProvider`/`LiveSttSession` 추상) + `voice/stt/mock_live.py`(키 없을 때 fallback) + `voice/stt/live_factory.py`(`LIVE_STT_PROVIDER=auto`면 DEEPGRAM_API_KEY 보유 시 deepgram_live).
- FastAPI WS 엔드포인트 `/internal/voice/stream`(`api/voice_stream.py`): RealTime이 프록시한 오디오를 받아 부분/최종 자막을 다운 프레임(`transcript.partial`/`transcript.final`)으로 보내고, 발화 종료(`stop` 또는 UtteranceEnd) 시 메트릭 계산 후 `callback.voice` 발행 → 기존 followup 파이프라인 재사용.
- **STT 타임아웃 분리 + 재시도** (`voice/stt/deepgram.py`, `messaging/consumers/voice_consumer.py`): Deepgram 배치 호출이 간헐적으로 응답 없이 멎는다 — 운영 실패 2건이 60.8s/61.5s 로 read 한도에 정확히 걸렸고 성공 호출은 p50 3.6초/최대 12초였다. 오디오 크기·손상·인증·모델·로컬 네트워크는 실측으로 배제됐고(실패 파일 6개 중 5개가 재전송에서 즉시 전사), 상류가 구간적으로 멎는 것이 원인. 일괄 `timeout=60` 을 `httpx.Timeout(read=30, connect=5)` 으로 쪼개 연결 막힘에 1분을 쓰지 않게 하고, `VoiceConsumer` 가 `SttError.retriable` 을 **읽어** 지수 백오프로 재시도한다(`STT_MAX_ATTEMPTS` 기본 3, `STT_RETRY_BACKOFF_SEC` 0.5). 비재시도(STT_AUTH_FAILED/STT_BAD_REQUEST)와 예상 못 한 예외는 재시도 없이 즉시 실패 콜백. 타임아웃 예외는 `str()` 이 빈 문자열이라 메시지에 예외 타입을 같이 남긴다(이전 로그는 `Deepgram 호출 실패: ` 로 끝나 connect/read 구분이 불가능했다). `ai_request_logs` 는 시도마다 한 행 — `error_message` 앞의 `[n/N]` 으로 재시도율을 본다.
- **STT 타임아웃 분리 + 재시도** (`voice/stt/deepgram.py`, `messaging/consumers/voice_consumer.py`): Deepgram 배치 호출이 간헐적으로 응답 없이 멎는다 — 운영 실패 2건이 60.8s/61.5s 로 read 한도에 정확히 걸렸고 성공 호출은 p50 3.6초/최대 12초였다. 오디오 크기·손상·인증·모델·로컬 네트워크는 실측으로 배제됐고(실패 파일 6개 중 5개가 재전송에서 즉시 전사), 상류가 구간적으로 멎는 것이 원인. 일괄 `timeout=60` 을 `httpx.Timeout(read=20, connect=5)` 으로 쪼개 연결 막힘에 1분을 쓰지 않게 하고, `VoiceConsumer` 가 `SttError.retriable` 을 **읽어** 지수 백오프로 재시도한다(`STT_MAX_ATTEMPTS` 기본 3, `STT_RETRY_BACKOFF_SEC` 0.5). 비재시도(STT_AUTH_FAILED/STT_BAD_REQUEST)와 예상 못 한 예외는 재시도 없이 즉시 실패 콜백. 타임아웃 예외는 `str()` 이 빈 문자열이라 메시지에 예외 타입을 같이 남긴다(이전 로그는 `Deepgram 호출 실패: ` 로 끝나 connect/read 구분이 불가능했다). `ai_request_logs` 는 시도마다 한 행 — `error_message` 앞의 `[n/N]` 으로 재시도율을 본다.
- **STT 환각 제거** (`voice/stt/sanitize.py`): Whisper/Deepgram 이 발화 끝 무음·잡음에서 학습데이터(방송/유튜브) 정형 문구를 환각으로 덧붙이는 문제(예: "MBC 뉴스 OOO입니다", "시청해주셔서 감사합니다", "구독과 좋아요", 영어 "thanks for watching")를 보수적으로 제거. 배치(`deepgram`/`openai_whisper`)는 `transcribe` 반환 시, 라이브(`deepgram_live`)는 **최종 자막마다** + `result()` 백스톱에서 적용 → 저장 전사·메트릭·실시간 표시 모두 정화. 환각만 남은 segment 는 제거해 무음/발음 메트릭이 실제 무음 반영. "감사합니다"·"뉴스 앱" 등 정상 표현은 보존.
- 추상화 계층 두기: `voice/stt/base.py` (interface), `voice/stt/whisper_api.py`, `voice/tts/base.py` + `voice/tts/{provider}.py`
- 분석:
Expand Down Expand Up @@ -420,8 +420,10 @@ docker run --env-file .env -p 8000:8000 stackup-ai
`attemptId` 를 성공/FAILED 콜백에 에코 — Core 가 대체된 이전 시도의 지연 FAILED 를 드롭하는
근거(messaging.md §5.10/§5.11). Core 쪽 처리는
[`backend/CLAUDE.md`](../backend/CLAUDE.md) 참고. (질문 풀 RAG `questions_rag_timeout_sec`
1.5s 하드 타임아웃과 `llm_pro_timeout_sec` 30s/`llm_flash_timeout_sec` 10s 요청 타임아웃은
이전 리팩터에서 도입되어 유지된다.)
1.5s 하드 타임아웃과 `llm_pro_timeout_sec` 60s/`llm_flash_timeout_sec` 10s 요청 타임아웃은
이전 리팩터에서 도입되어 유지된다 — Pro 는 30s 로 시작했으나 운영 성공 호출이 p90 31.9초,
최대 56초(=타임아웃 30초 + 내부 재시도 26초)로 찍히고 문서 분석이 3회 모두 30초를 넘겨
영구 실패해 60s 로 올렸다.)

- **질문 풀·피드백 생성 진행 이벤트 본 구현 (B2)**: 스트리밍이 없는 두 블로킹 생성 경로(질문 풀 Pro ≤30s,
피드백 병렬 gather ≈2분 예산)가 진행 중 무통보였던 것을 고쳤다. `SessionRealtimeNotifier.emit_progress`
Expand Down
12 changes: 8 additions & 4 deletions ai/src/ai_server/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ class Settings(BaseSettings):
deepgram_base_url: str = "https://api.deepgram.com/v1"
deepgram_model: str = "whisper-large" # 한국어 정확도 우선; 저비용 우선 시 nova-2.
deepgram_language: str = "ko"
# read 한도. 정상 호출은 p50 3.6초/최대 12초라 30초도 크게 여유롭다.
# 이전 60초는 Deepgram 이 멎었을 때 사용자를 1분간 붙잡아 두기만 했다.
deepgram_timeout_sec: float = 30.0
# read 한도. 운영 성공 호출은 p50 3.8초/p90 8.1초/최대 12.1초라 20초면 1.65배 여유다.
# 멎은 호출을 붙잡는 시간이 곧 사용자 대기 시간이므로(재시도 전 낭비) 짧을수록 좋다.
deepgram_timeout_sec: float = 20.0
deepgram_connect_timeout_sec: float = 5.0
# STT 재시도. Deepgram 이 간헐적으로 응답 없이 멎는데, 같은 오디오를 재전송하면
# 대부분 즉시 전사된다. retriable 오류에만 적용(인증·잘못된 요청은 즉시 실패).
Expand Down Expand Up @@ -106,7 +106,11 @@ class Settings(BaseSettings):
llm_pro_model: str = "gemini-3.1-pro-preview"
llm_pro_temperature: float = 0.2
# 요청 타임아웃 미설정 시 SDK 기본값(수백 초)까지 무기한 대기할 수 있어 명시.
llm_pro_timeout_sec: float = 30.0
# Pro 는 입력이 커서 한 번에 오래 걸린다 — 운영 성공 호출이 synthesis p90 31.9초,
# questions 최대 25.7초였고, 30초에 걸려 내부 재시도로 겨우 성공한 건이 56초로 찍혔다
# (타임아웃 30초 + 재시도 26초). 문서 분석은 3번 모두 30초를 넘겨 영구 실패했다.
# 60초면 관측 최대(31.9초)의 1.9배 — 재시도로 같은 호출을 두 번 물지 않게 한다.
llm_pro_timeout_sec: float = 60.0

# 꼬리질문용 Flash 모델 (저지연 < 3s)
llm_flash_model: str = "gemini-3.5-flash-lite"
Expand Down
10 changes: 10 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,16 @@ docker compose up -d
덮어쓴다(`VOICE_RETRANSCRIBE_NOT_ALLOWED`). 프론트는 스테이지 인라인 배너 + 대화 기록 드로어
양쪽에 '다시 인식하기' 를 낸다(스테이지엔 실패 안내가 아예 없어서 사용자가 왜 답변이 사라졌는지
모른 채 다시 말해야 했다).
- **분석 실패 재분석 본 구현**: 분석이 실패하면 되돌릴 수 없었다 — 재분석 API 가 없어 사용자는
자료를 지우고 다시 등록해야 했고(PDF 면 파일 재업로드), 화면에는 AI 서버의 예외 문자열이
그대로 노출됐다(운영에서 웹 이력서 1건이 `APITimeoutError: Request timed out.` 로 사흘째 FAILED).
원본(S3 파일·URL·자소서 본문)은 살아 있으므로 다시 돌리기만 하면 된다.
`POST /api/documents/{id}/reanalyze`(`DocumentReanalysisService`, 202)가 실패 문서를 soft delete 하고
출처(resume/web/repository/coverLetter)에 맞는 `AnalysisRequestService.request*Analysis` 로 **새**
AnalyzedDocument 를 만들어 재발행한다 — 실패 행을 남기면 목록에 실패 카드와 진행 카드가 나란히 보인다.
FAILED 가 아닌 문서는 `DOC_REANALYZE_NOT_ALLOWED`. 프론트는 `errorCode` 로 고른 한국어 문구만 쓰고
(`features/analysis/lib/analysisError.ts`) 원문은 서버 로그용으로만 남기며, 재시도해도 결과가 같은
실패(빈 PDF·차단된 URL 등)에는 버튼을 내지 않는다. 음성 답변 재전사와 같은 성격의 2차 방어선.
- **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당.
- **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로.

Expand Down
70 changes: 70 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1541,6 +1541,65 @@
}
}
},
"/api/documents/{documentId}/reanalyze" : {
"post" : {
"tags" : [ "Documents" ],
"summary" : "분석에 실패한 문서 다시 분석",
"description" : "원본(S3 파일·URL·자소서 본문)은 그대로 살아 있으므로 같은 자료로 분석을 다시 요청한다. 자료를 지우고 다시 등록할 필요가 없다. 새 분석 문서가 만들어지고 실패한 문서는 목록에서 사라진다.",
"operationId" : "reanalyzeDocument",
"parameters" : [ {
"name" : "documentId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
} ],
"responses" : {
"202" : {
"description" : "재분석 요청 발행 — 새 문서 id 반환",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/ReanalyzeDocumentResponse"
}
}
}
},
"401" : {
"description" : "인증 실패",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/ReanalyzeDocumentResponse"
}
}
}
},
"404" : {
"description" : "문서 없음",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/ReanalyzeDocumentResponse"
}
}
}
},
"422" : {
"description" : "실패 상태가 아니라 다시 분석할 대상이 아님",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/ReanalyzeDocumentResponse"
}
}
}
}
}
}
},
"/api/cover-letters" : {
"get" : {
"tags" : [ "CoverLetters" ],
Expand Down Expand Up @@ -3887,6 +3946,17 @@
},
"required" : [ "requestType", "status" ]
},
"ReanalyzeDocumentResponse" : {
"type" : "object",
"description" : "재분석 요청 결과 — 새로 만들어진 분석 문서 id",
"properties" : {
"documentId" : {
"type" : "integer",
"format" : "int64",
"description" : "새 분석 문서 id (PROCESSING 상태로 시작)"
}
}
},
"CoverLetterCreateRequest" : {
"type" : "object",
"properties" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public enum ApiErrorCode {
DOC_NOT_ANALYZED(HttpStatus.UNPROCESSABLE_CONTENT, "아직 분석되지 않은 문서입니다."),
DOC_ANALYSIS_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "문서 분석에 실패했습니다."),
DOC_NOT_FOUND(HttpStatus.NOT_FOUND, "분석 문서를 찾을 수 없습니다."),
DOC_REANALYZE_NOT_ALLOWED(HttpStatus.UNPROCESSABLE_CONTENT,
"다시 분석할 수 있는 문서가 아닙니다."),
INTERNAL_AUTH_FAILED(HttpStatus.UNAUTHORIZED, "내부 API 인증에 실패했습니다."),
EMBEDDING_BAD_REQUEST(HttpStatus.BAD_REQUEST, "임베딩 요청 페이로드가 올바르지 않습니다."),

Expand All @@ -55,7 +57,7 @@ public enum ApiErrorCode {
VOICE_INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 음성 형식입니다."),
VOICE_MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "음성 메시지를 찾을 수 없습니다."),
VOICE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "음성 파일 업로드에 실패했습니다."),
VOICE_RETRANSCRIBE_NOT_ALLOWED(HttpStatus.UNPROCESSABLE_ENTITY,
VOICE_RETRANSCRIBE_NOT_ALLOWED(HttpStatus.UNPROCESSABLE_CONTENT,
"다시 인식할 수 있는 음성 답변이 아닙니다."),
MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "메시지를 찾을 수 없습니다."),
MESSAGE_NOT_BOOKMARKABLE(HttpStatus.UNPROCESSABLE_ENTITY, "질문만 오답노트에 담을 수 있습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.stackup.stackup.document.application;

import com.stackup.stackup.common.exception.ApiErrorCode;
import com.stackup.stackup.common.exception.DomainException;
import com.stackup.stackup.document.application.AnalysisRequestService.AnalysisHandle;
import com.stackup.stackup.document.domain.AnalysisStatus;
import com.stackup.stackup.document.domain.AnalyzedDocument;
import com.stackup.stackup.document.domain.AnalyzedDocumentRepository;
import com.stackup.stackup.resume.domain.Resume;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

// 분석에 실패한 문서를 같은 원본으로 다시 분석한다.
//
// 지금까지 분석 실패는 되돌릴 수 없었다 — 재분석 API 가 없어서 사용자는 자료를 지우고 다시
// 등록해야 했다(PDF 면 파일을 다시 업로드). 그런데 실패의 상당수는 LLM 호출 타임아웃 같은
// 일시 장애다(운영에서 웹 이력서 1건이 `APITimeoutError` 로 사흘째 FAILED 로 남아 있었다).
// 원본(S3 파일·URL·자소서 본문)은 그대로 살아 있으므로 다시 돌리기만 하면 된다.
//
// 음성 답변의 재전사(`VoiceRetranscribeService`)와 같은 성격의 2차 방어선이다.
@Service
@RequiredArgsConstructor
public class DocumentReanalysisService {

private static final Logger log = LoggerFactory.getLogger(DocumentReanalysisService.class);

private final AnalyzedDocumentRepository documentRepository;
private final AnalysisRequestService analysisRequestService;

@Transactional
public AnalysisHandle reanalyze(Long userId, Long documentId) {
AnalyzedDocument doc = documentRepository.findActiveByIdAndOwner(documentId, userId)
.orElseThrow(() -> new DomainException(ApiErrorCode.DOC_NOT_FOUND));
if (doc.getAnalysisStatus() != AnalysisStatus.FAILED) {
throw new DomainException(ApiErrorCode.DOC_REANALYZE_NOT_ALLOWED);
}

// 재분석은 새 AnalyzedDocument 를 만든다(기존 요청 경로와 동일). 실패한 행을 남겨 두면
// 목록에 실패 카드와 진행 카드가 나란히 보이므로 여기서 치운다.
doc.markDeleted();

AnalysisHandle handle = dispatch(userId, doc);
log.info("document reanalysis requested. userId={}, failedDocumentId={}, newDocumentId={}",
userId, documentId, handle.analyzedDocumentId());
return handle;
}

private AnalysisHandle dispatch(Long userId, AnalyzedDocument doc) {
Resume resume = doc.getResume();
if (resume != null) {
// 웹 이력서와 PDF 는 같은 Resume 행을 쓰고 locator 만 다르다(source_url vs file_path).
return hasSourceUrl(resume)
? analysisRequestService.requestWebResumeAnalysis(userId, resume.getId())
: analysisRequestService.requestResumeAnalysis(userId, resume.getId());
}
if (doc.getRepository() != null) {
return analysisRequestService.requestRepositoryAnalysis(
userId, doc.getRepository().getId());
}
if (doc.getCoverLetter() != null) {
return analysisRequestService.requestCoverLetterAnalysis(
userId, doc.getCoverLetter().getId());
}
// single_source CHECK 제약상 도달 불가 — 방어적으로 막는다.
throw new DomainException(ApiErrorCode.DOC_REANALYZE_NOT_ALLOWED);
}

private static boolean hasSourceUrl(Resume resume) {
return resume.getSourceUrl() != null && !resume.getSourceUrl().isBlank();
}
}
Loading
Loading