diff --git a/.env.example b/.env.example index 68ccf6ab..54fc4856 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -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 diff --git a/ai/.env.example b/ai/.env.example index 6a20355c..eab49e4e 100644 --- a/ai/.env.example +++ b/ai/.env.example @@ -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 @@ -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) diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index 92f7f955..a3f6fb29 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -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` - 분석: @@ -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` diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index b8242ee1..966aef0e 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -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 오류에만 적용(인증·잘못된 요청은 즉시 실패). @@ -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" diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index a738cd65..2aaa73c1 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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 레코드 또는 인메모리로. diff --git a/backend/openapi.json b/backend/openapi.json index 6f037d3b..a4b27f33 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -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" ], @@ -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" : { diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java index 0425f72a..e363f978 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java @@ -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, "임베딩 요청 페이로드가 올바르지 않습니다."), @@ -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, "질문만 오답노트에 담을 수 있습니다."), diff --git a/backend/src/main/java/com/stackup/stackup/document/application/DocumentReanalysisService.java b/backend/src/main/java/com/stackup/stackup/document/application/DocumentReanalysisService.java new file mode 100644 index 00000000..b476abed --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/DocumentReanalysisService.java @@ -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(); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/presentation/AnalyzedDocumentController.java b/backend/src/main/java/com/stackup/stackup/document/presentation/AnalyzedDocumentController.java index 9cb89886..bb3764a1 100644 --- a/backend/src/main/java/com/stackup/stackup/document/presentation/AnalyzedDocumentController.java +++ b/backend/src/main/java/com/stackup/stackup/document/presentation/AnalyzedDocumentController.java @@ -1,7 +1,10 @@ package com.stackup.stackup.document.presentation; import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.document.application.AnalysisRequestService.AnalysisHandle; import com.stackup.stackup.document.application.AnalyzedDocumentQueryService; +import com.stackup.stackup.document.application.DocumentReanalysisService; +import com.stackup.stackup.document.presentation.dto.ReanalyzeDocumentResponse; import com.stackup.stackup.document.presentation.dto.AnalyzedDocumentResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -13,13 +16,16 @@ import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.http.CacheControl; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @Tag(name = "Documents", description = "분석 문서(이력서/레포 공통) 조회. 상세 조회 시 분석 마크다운의 presigned S3 URL 포함.") @@ -29,6 +35,7 @@ public class AnalyzedDocumentController { private final AnalyzedDocumentQueryService queryService; + private final DocumentReanalysisService reanalysisService; @Operation( operationId = "listAnalyzedDocuments", @@ -90,4 +97,27 @@ public ResponseEntity content( .cacheControl(CacheControl.maxAge(Duration.ofMinutes(10)).cachePrivate()) .body(new InputStreamResource(queryService.getContentForUser(principal.userId(), documentId))); } + + @Operation( + operationId = "reanalyzeDocument", + summary = "분석에 실패한 문서 다시 분석", + description = "원본(S3 파일·URL·자소서 본문)은 그대로 살아 있으므로 같은 자료로 분석을 다시 " + + "요청한다. 자료를 지우고 다시 등록할 필요가 없다. 새 분석 문서가 만들어지고 " + + "실패한 문서는 목록에서 사라진다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "202", description = "재분석 요청 발행 — 새 문서 id 반환"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "문서 없음"), + @ApiResponse(responseCode = "422", description = "실패 상태가 아니라 다시 분석할 대상이 아님") + }) + @PostMapping("/{documentId}/reanalyze") + @ResponseStatus(HttpStatus.ACCEPTED) + public ReanalyzeDocumentResponse reanalyze( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long documentId + ) { + AnalysisHandle handle = reanalysisService.reanalyze(principal.userId(), documentId); + return new ReanalyzeDocumentResponse(handle.analyzedDocumentId()); + } } diff --git a/backend/src/main/java/com/stackup/stackup/document/presentation/dto/ReanalyzeDocumentResponse.java b/backend/src/main/java/com/stackup/stackup/document/presentation/dto/ReanalyzeDocumentResponse.java new file mode 100644 index 00000000..56be523c --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/presentation/dto/ReanalyzeDocumentResponse.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.document.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "재분석 요청 결과 — 새로 만들어진 분석 문서 id") +public record ReanalyzeDocumentResponse( + @Schema(description = "새 분석 문서 id (PROCESSING 상태로 시작)") Long documentId +) { +} diff --git a/backend/src/test/java/com/stackup/stackup/document/application/DocumentReanalysisServiceTest.java b/backend/src/test/java/com/stackup/stackup/document/application/DocumentReanalysisServiceTest.java new file mode 100644 index 00000000..48458cbc --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/document/application/DocumentReanalysisServiceTest.java @@ -0,0 +1,151 @@ +package com.stackup.stackup.document.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.coverletter.domain.CoverLetter; +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.github.domain.GithubRepository; +import com.stackup.stackup.resume.domain.Resume; +import com.stackup.stackup.resume.domain.ResumeFileType; +import com.stackup.stackup.user.domain.User; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class DocumentReanalysisServiceTest { + + @Mock AnalyzedDocumentRepository documentRepository; + @Mock AnalysisRequestService analysisRequestService; + @InjectMocks DocumentReanalysisService service; + + @Test + void reanalyze_webResume_republishesWebAnalysis() { + AnalyzedDocument doc = failedDocFor(webResume(18L, "https://portfolio.example.com/")); + when(documentRepository.findActiveByIdAndOwner(34L, 1L)).thenReturn(Optional.of(doc)); + when(analysisRequestService.requestWebResumeAnalysis(1L, 18L)) + .thenReturn(new AnalysisHandle(99L, 18L, null)); + + AnalysisHandle handle = service.reanalyze(1L, 34L); + + assertThat(handle.analyzedDocumentId()).isEqualTo(99L); + // 실패 행을 남기면 목록에 실패 카드와 진행 카드가 나란히 보인다. + assertThat(doc.isDeleted()).isTrue(); + verify(analysisRequestService, never()).requestResumeAnalysis(anyLong(), anyLong()); + } + + @Test + void reanalyze_fileResume_republishesFileAnalysis() { + AnalyzedDocument doc = failedDocFor(fileResume(5L)); + when(documentRepository.findActiveByIdAndOwner(20L, 1L)).thenReturn(Optional.of(doc)); + when(analysisRequestService.requestResumeAnalysis(1L, 5L)) + .thenReturn(new AnalysisHandle(77L, 5L, null)); + + service.reanalyze(1L, 20L); + + verify(analysisRequestService).requestResumeAnalysis(1L, 5L); + verify(analysisRequestService, never()).requestWebResumeAnalysis(anyLong(), anyLong()); + } + + @Test + void reanalyze_repository_republishesRepositoryAnalysis() { + GithubRepository repo = repository(7L); + AnalyzedDocument doc = failed(AnalyzedDocument.forRepository(repo)); + when(documentRepository.findActiveByIdAndOwner(21L, 1L)).thenReturn(Optional.of(doc)); + when(analysisRequestService.requestRepositoryAnalysis(1L, 7L)) + .thenReturn(new AnalysisHandle(78L, null, 7L)); + + service.reanalyze(1L, 21L); + + verify(analysisRequestService).requestRepositoryAnalysis(1L, 7L); + } + + @Test + void reanalyze_coverLetter_republishesCoverLetterAnalysis() { + CoverLetter cl = CoverLetter.create(user(), "지원 동기", "[]"); + ReflectionTestUtils.setField(cl, "id", 3L); + AnalyzedDocument doc = failed(AnalyzedDocument.forCoverLetter(cl)); + when(documentRepository.findActiveByIdAndOwner(22L, 1L)).thenReturn(Optional.of(doc)); + when(analysisRequestService.requestCoverLetterAnalysis(1L, 3L)) + .thenReturn(new AnalysisHandle(79L, null, null)); + + service.reanalyze(1L, 22L); + + verify(analysisRequestService).requestCoverLetterAnalysis(1L, 3L); + } + + @Test + void reanalyze_rejectsDocumentThatDidNotFail() { + AnalyzedDocument doc = AnalyzedDocument.forResume(fileResume(5L)); + doc.markAnalyzed("analyzed/resume/5.md", "요약", "[]", 3); + when(documentRepository.findActiveByIdAndOwner(20L, 1L)).thenReturn(Optional.of(doc)); + + assertThatThrownBy(() -> service.reanalyze(1L, 20L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.DOC_REANALYZE_NOT_ALLOWED); + verifyNoInteractions(analysisRequestService); + } + + @Test + void reanalyze_rejectsDocumentNotOwnedByUser() { + when(documentRepository.findActiveByIdAndOwner(20L, 1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.reanalyze(1L, 20L)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("errorCode", ApiErrorCode.DOC_NOT_FOUND); + verifyNoInteractions(analysisRequestService); + } + + // ── fixtures ────────────────────────────────────────────────────────────── + + private AnalyzedDocument failedDocFor(Resume resume) { + return failed(AnalyzedDocument.forResume(resume)); + } + + private AnalyzedDocument failed(AnalyzedDocument doc) { + doc.markFailed("UNEXPECTED", "APITimeoutError: Request timed out."); + assertThat(doc.getAnalysisStatus()).isEqualTo(AnalysisStatus.FAILED); + return doc; + } + + private Resume webResume(Long id, String url) { + Resume r = Resume.createWeb(user(), "portfolio", url); + ReflectionTestUtils.setField(r, "id", id); + return r; + } + + private Resume fileResume(Long id) { + Resume r = Resume.create( + user(), "resume.pdf", "resume/1/resume.pdf", ResumeFileType.PDF, 1024L); + ReflectionTestUtils.setField(r, "id", id); + return r; + } + + private GithubRepository repository(Long id) { + GithubRepository repo = GithubRepository.create( + user(), 1234L, "app", "me/app", "https://github.com/me/app", "main"); + ReflectionTestUtils.setField(repo, "id", id); + return repo; + } + + private User user() { + User u = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(u, "id", 1L); + return u; + } +} diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 00000000..202e4f4c Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/environment.md b/docs/environment.md index 671fd464..1df64847 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -102,7 +102,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 # 요청 타임아웃(초). 미설정 시 SDK 기본값까지 무기한 대기 위험 +LLM_PRO_TIMEOUT_SEC=60.0 # 요청 타임아웃(초). 운영 성공 호출 p90 31.9초·최대 56초(재시도 포함)라 30초는 짧았다 LLM_FLASH_MODEL=gemini-3.5-flash-lite # 꼬리질문(US-19) 저지연 모델 LLM_FLASH_TEMPERATURE=0.4 LLM_FLASH_MAX_TOKENS=512 @@ -164,7 +164,7 @@ STT_PROVIDER=auto # auto | mock | deepgram (auto=DEEPGRAM_API_KE DEEPGRAM_BASE_URL=https://api.deepgram.com/v1 DEEPGRAM_MODEL=whisper-large # 한국어 정확도 우선; 저비용 우선 시 nova-2 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 한도 — 일괄 timeout 이면 연결 막힘도 read 한도만큼 붙잡는다 STT_MAX_ATTEMPTS=3 # 일시 장애(retriable) 재시도 횟수. 1 이면 재시도 없음 STT_RETRY_BACKOFF_SEC=0.5 # 지수 백오프 기준값 (0.5 → 0.5s, 1s) diff --git a/docs/research/.DS_Store b/docs/research/.DS_Store new file mode 100644 index 00000000..f6a75693 Binary files /dev/null and b/docs/research/.DS_Store differ diff --git a/docs/research/llm-eval-2026-09/.DS_Store b/docs/research/llm-eval-2026-09/.DS_Store new file mode 100644 index 00000000..78476e0b Binary files /dev/null and b/docs/research/llm-eval-2026-09/.DS_Store differ diff --git a/frontend/src/features/analysis/api/analysis.ts b/frontend/src/features/analysis/api/analysis.ts index 9bae1321..0fb8635d 100644 --- a/frontend/src/features/analysis/api/analysis.ts +++ b/frontend/src/features/analysis/api/analysis.ts @@ -20,3 +20,11 @@ export async function fetchDocument(id: number): Promise { const response = await apiClient.get(`/api/documents/${id}`) return response.data } + +// 분석에 실패한 문서를 같은 원본으로 다시 분석. 새 문서가 만들어지고 실패한 문서는 사라진다. +export async function reanalyzeDocument(id: number): Promise<{ documentId: number }> { + const response = await apiClient.post<{ documentId: number }>( + `/api/documents/${id}/reanalyze`, + ) + return response.data +} diff --git a/frontend/src/features/analysis/lib/analysisError.test.ts b/frontend/src/features/analysis/lib/analysisError.test.ts new file mode 100644 index 00000000..33b01192 --- /dev/null +++ b/frontend/src/features/analysis/lib/analysisError.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { analysisFailureMessage, isRetriableAnalysisFailure } from './analysisError' + +describe('analysisFailureMessage', () => { + it('내부 예외 문자열 대신 사람 말을 낸다', () => { + // 운영에서 "APITimeoutError: Request timed out." 이 한글 UI 에 그대로 노출됐다. + const msg = analysisFailureMessage('UNEXPECTED') + expect(msg).toBe('AI 분석이 제때 끝나지 않았어요. 다시 시도해 주세요.') + expect(msg).not.toMatch(/Error|timed out/i) + }) + + it('모르는 코드·코드 없음도 사람 말로 폴백한다', () => { + expect(analysisFailureMessage('SOMETHING_NEW')).toBe( + '분석에 실패했어요. 다시 시도해 주세요.', + ) + expect(analysisFailureMessage(null)).toBe('분석에 실패했어요. 다시 시도해 주세요.') + }) + + it('영문 예외 문구가 새어 나가지 않는다', () => { + for (const code of ['UNEXPECTED', 'GEMINI_RATE_LIMITED', 'WEB_FETCH_FAILED', null]) { + expect(analysisFailureMessage(code)).not.toMatch(/[A-Za-z]{6,}Error/) + } + }) +}) + +describe('isRetriableAnalysisFailure', () => { + it('일시 장애는 재시도를 권한다', () => { + expect(isRetriableAnalysisFailure('UNEXPECTED')).toBe(true) + expect(isRetriableAnalysisFailure('GEMINI_RATE_LIMITED')).toBe(true) + expect(isRetriableAnalysisFailure('WEB_FETCH_FAILED')).toBe(true) + }) + + it('자료 자체가 문제면 재시도 버튼을 내지 않는다 — 눌러도 같은 자리로 돌아온다', () => { + expect(isRetriableAnalysisFailure('EMPTY_PDF_TEXT')).toBe(false) + expect(isRetriableAnalysisFailure('BLOCKED_WEB_URL')).toBe(false) + expect(isRetriableAnalysisFailure('WEB_HOST_UNRESOLVED')).toBe(false) + }) + + it('알 수 없는 코드는 일단 재시도를 허용한다', () => { + expect(isRetriableAnalysisFailure('SOMETHING_NEW')).toBe(true) + expect(isRetriableAnalysisFailure(null)).toBe(true) + }) +}) diff --git a/frontend/src/features/analysis/lib/analysisError.ts b/frontend/src/features/analysis/lib/analysisError.ts new file mode 100644 index 00000000..65474dc7 --- /dev/null +++ b/frontend/src/features/analysis/lib/analysisError.ts @@ -0,0 +1,50 @@ +// 분석 실패 사유를 사람 말로. 백엔드가 errorMessage 에 실어 보내는 건 AI 서버의 예외 문자열이라 +// (운영에서 "APITimeoutError: Request timed out." 이 한글 UI 에 그대로 노출됐다) 화면에는 +// errorCode 로 고른 문구만 쓴다. 원문은 서버 로그·지원 문의용으로만 남긴다. +const MESSAGE: Record = { + // 일시 장애 — 다시 시도하면 대체로 풀린다 + UNEXPECTED: 'AI 분석이 제때 끝나지 않았어요. 다시 시도해 주세요.', + GEMINI_RATE_LIMITED: '요청이 몰려 분석이 밀렸어요. 잠시 후 다시 시도해 주세요.', + WEB_FETCH_FAILED: '사이트를 불러오지 못했어요. 주소가 열려 있는지 확인하고 다시 시도해 주세요.', + REPO_AUTH_OR_RATE: 'GitHub 요청 한도에 걸렸어요. 잠시 후 다시 시도해 주세요.', + EMBED_COUNT_MISMATCH: '분석 결과를 저장하는 중 문제가 생겼어요. 다시 시도해 주세요.', + + // 자료 자체의 문제 — 다시 시도해도 같다 + EMPTY_PDF_TEXT: '파일에서 글자를 찾지 못했어요. 스캔 이미지라면 텍스트 PDF 로 다시 올려 주세요.', + EMPTY_WEB_BODY: '페이지에서 읽을 내용을 찾지 못했어요.', + EMPTY_REPO_CONTENT: '레포지토리에서 분석할 코드를 찾지 못했어요.', + WEB_NOT_HTML: '웹페이지가 아니라 분석할 수 없어요.', + WEB_HTML_TOO_LARGE: '페이지가 너무 커서 분석할 수 없어요.', + WEB_HTTP_STATUS: '사이트가 오류를 돌려줬어요. 주소를 확인해 주세요.', + WEB_HOST_UNRESOLVED: '주소를 찾을 수 없어요. 오타가 없는지 확인해 주세요.', + WEB_TOO_MANY_REDIRECTS: '주소가 너무 여러 번 이동해 분석할 수 없어요.', + INVALID_WEB_URL: '지원하지 않는 주소 형식이에요.', + BLOCKED_WEB_URL: '접근할 수 없는 주소예요.', + REPO_NOT_FOUND: '레포지토리를 찾을 수 없어요. 접근 권한을 확인해 주세요.', + INVALID_REPO_LOCATOR: '레포지토리 주소를 알아볼 수 없어요.', + DOCUMENT_NOT_FOUND: '원본 파일을 찾을 수 없어요.', +} + +// 다시 시도해도 같은 결과가 나오는 실패. 이 경우 재분석 버튼을 내지 않는다 — +// 눌러도 같은 자리로 돌아오면 사용자는 고장으로 받아들인다. +const PERMANENT = new Set([ + 'EMPTY_PDF_TEXT', + 'EMPTY_WEB_BODY', + 'EMPTY_REPO_CONTENT', + 'WEB_NOT_HTML', + 'WEB_HTML_TOO_LARGE', + 'WEB_HOST_UNRESOLVED', + 'WEB_TOO_MANY_REDIRECTS', + 'INVALID_WEB_URL', + 'BLOCKED_WEB_URL', + 'INVALID_REPO_LOCATOR', + 'DOCUMENT_NOT_FOUND', +]) + +export function analysisFailureMessage(errorCode?: string | null): string { + return (errorCode && MESSAGE[errorCode]) || '분석에 실패했어요. 다시 시도해 주세요.' +} + +export function isRetriableAnalysisFailure(errorCode?: string | null): boolean { + return !errorCode || !PERMANENT.has(errorCode) +} diff --git a/frontend/src/features/analysis/model/useDocuments.ts b/frontend/src/features/analysis/model/useDocuments.ts index c8791881..d6496690 100644 --- a/frontend/src/features/analysis/model/useDocuments.ts +++ b/frontend/src/features/analysis/model/useDocuments.ts @@ -1,5 +1,6 @@ -import { useQuery } from '@tanstack/react-query' -import { fetchDocuments, type DocumentFilter } from '../api/analysis' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from '@/shared/ui' +import { fetchDocuments, reanalyzeDocument, type DocumentFilter } from '../api/analysis' import type { AnalyzedDocument } from './types' export const documentKeys = { @@ -13,3 +14,13 @@ export function useDocuments(filter: DocumentFilter = {}) { queryFn: () => fetchDocuments(filter), }) } + +export function useReanalyzeDocument() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (documentId: number) => reanalyzeDocument(documentId), + // 실패 문서가 사라지고 PROCESSING 문서가 생기므로 목록 전체를 다시 받는다. + onSuccess: () => queryClient.invalidateQueries({ queryKey: documentKeys.all }), + onError: () => toast.error('다시 분석하지 못했어요. 잠시 후 시도해 주세요.'), + }) +} diff --git a/frontend/src/features/analysis/ui/DocumentList.tsx b/frontend/src/features/analysis/ui/DocumentList.tsx index 8a32d6ac..97bea219 100644 --- a/frontend/src/features/analysis/ui/DocumentList.tsx +++ b/frontend/src/features/analysis/ui/DocumentList.tsx @@ -2,7 +2,11 @@ import { useState } from 'react' import { EmptyState, ListSkeleton, Markdown, Modal, StatusBadge, type StatusTone, QueryError } from '@/shared/ui' import { DOCUMENT_SOURCE_LABEL as SOURCE_LABEL } from '@/domain/rag' import type { DocumentFilter } from '../api/analysis' -import { useDocuments } from '../model/useDocuments' +import { useDocuments, useReanalyzeDocument } from '../model/useDocuments' +import { + analysisFailureMessage, + isRetriableAnalysisFailure, +} from '../lib/analysisError' import { useDocumentMarkdown } from '../model/useDocumentMarkdown' import type { AnalysisSourceType, @@ -28,6 +32,7 @@ type Props = { export function DocumentList({ filter = {}, sourceType }: Props) { const { data = [], isPending, isError, refetch } = useDocuments(filter) const [activeId, setActiveId] = useState(null) + const { mutate: reanalyze, isPending: reanalyzing } = useReanalyzeDocument() if (isPending) { return @@ -60,6 +65,8 @@ export function DocumentList({ filter = {}, sourceType }: Props) { key={doc.id} doc={doc} onOpen={() => setActiveId(doc.id)} + onReanalyze={() => reanalyze(doc.id)} + reanalyzing={reanalyzing} /> ))} @@ -78,9 +85,13 @@ export function DocumentList({ filter = {}, sourceType }: Props) { function DocumentCard({ doc, onOpen, + onReanalyze, + reanalyzing, }: { doc: AnalyzedDocument onOpen: () => void + onReanalyze: () => void + reanalyzing: boolean }) { const meta = STATUS_META[doc.analysisStatus] const clickable = doc.analysisStatus === 'ANALYZED' @@ -133,9 +144,25 @@ function DocumentCard({ ) : null} {doc.analysisStatus === 'FAILED' ? ( -

- {doc.errorMessage ?? '분석에 실패했습니다.'} -

+
+

+ {analysisFailureMessage(doc.errorCode)} +

+ {isRetriableAnalysisFailure(doc.errorCode) ? ( + + ) : null} +
) : null} ) diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index d5c8d17f..f3d6998f 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -406,6 +406,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/{documentId}/reanalyze": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 분석에 실패한 문서 다시 분석 + * @description 원본(S3 파일·URL·자소서 본문)은 그대로 살아 있으므로 같은 자료로 분석을 다시 요청한다. 자료를 지우고 다시 등록할 필요가 없다. 새 분석 문서가 만들어지고 실패한 문서는 목록에서 사라진다. + */ + post: operations["reanalyzeDocument"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/cover-letters": { parameters: { query?: never; @@ -1294,6 +1314,14 @@ export interface components { status: string; errorMessage?: string; }; + /** @description 재분석 요청 결과 — 새로 만들어진 분석 문서 id */ + ReanalyzeDocumentResponse: { + /** + * Format: int64 + * @description 새 분석 문서 id (PROCESSING 상태로 시작) + */ + documentId?: number; + }; CoverLetterCreateRequest: { title?: string; items: components["schemas"]["Item"][]; @@ -2836,6 +2864,55 @@ export interface operations { }; }; }; + reanalyzeDocument: { + parameters: { + query?: never; + header?: never; + path: { + documentId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 재분석 요청 발행 — 새 문서 id 반환 */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ReanalyzeDocumentResponse"]; + }; + }; + /** @description 인증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ReanalyzeDocumentResponse"]; + }; + }; + /** @description 문서 없음 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ReanalyzeDocumentResponse"]; + }; + }; + /** @description 실패 상태가 아니라 다시 분석할 대상이 아님 */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ReanalyzeDocumentResponse"]; + }; + }; + }; + }; listCoverLetters: { parameters: { query?: never;