Skip to content

feat: 영상 행동 분석 검증·Web 응답과 종료 자격·OFF 복구·멱등성 보완 - #120

Merged
kimwuju merged 4 commits into
mainfrom
feat/video-behavior-analysis
Oct 20, 2026
Merged

kimwuju merged 4 commits into
mainfrom
feat/video-behavior-analysis

Conversation

@kimwuju

@kimwuju kimwuju commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

기준과 결과

  • 기준: main ae5471b1bab73ec2155bc4d8687824cb4ba86f51 (작업 중 feat: 문항별 예상 꼬리질문 저장·조회와 기업 코멘트 진단 로그 #119·fix/#117-retry-job-snapshot #118 병합으로 4223095 → 040ee7e → ae5471b로 두 번 갱신, 최종 기준에 rebase)
  • 결과: f6bd95c3741cab5b4f51b5c27da9e5e7b95faed2 (커밋 3개)
  • 대조 자료: Web PR #189 codex/behavior-feedback@29148cb의 docs/behavior-analysis(00·01·04·contracts), Analysis origin/main@8b33027의 behavior/validation.py·video-api.md, 이 저장소의 docs/abandoned-interview-feedback/
  • DB migration 없음(최신 V41 유지). 기존 result JSONB·unmatched_callback·부분 unique index를 그대로 쓴다.

구현 항목

A. 종료 자격

  • saveInterview의 null/IN_PROGRESS → COMPLETED 보정 제거. 새 기록은 COMPLETED/ABANDONED만 받고 그 외는 claim 전 422(면접은 IN_PROGRESS 유지). 모르는 상태 문자열도 400이 아니라 422.
  • 이미 종료된 면접은 받은 상태와 상관없이 첫 기록·질문 PK·최종 상태를 유지(no-op 2xx).
  • handleResult는 커밋 뒤 DB 상태를 다시 읽어 COMPLETED만 onTranscriptSaved. ABANDONED는 기록 보존·정상 응답(outbox 정리).
  • 공통 정책 InterviewFeedbackPolicy(RUN/SKIP/HOLD, 409 메시지)를 만들고 FeedbackService도 이를 쓴다. 영상은 VideoAnalysisEligibility로 텍스트 면접을 추가 제외.
  • 영상 경로 전부(신규 생성·재전송·GET 복구·자동/사용자 재분석·조회·늦은 callback)가 같은 판단을 거친다. 중단·텍스트 PENDING은 INTERVIEW_ABANDONED/TEXT_INTERVIEW로 외부 호출 없이 닫고, IN_PROGRESS는 닫지 않고 10분 간격으로 미룬다. 업로드 자체는 상태와 무관하게 보존(변경 없음).
  • 사용자 GET/retry: 소유권(403/404) 확인 뒤 비완료 409.

B. BehaviorData 검증기

  • analysis/behavior/: BehaviorValidator(validation.py 포팅 + JSON Schema 타입·범위), VideoResultDocument(포장 상태 일치·ready/partial/unavailable/failed 모양·videoId/durationMs 묶음·품질 판정 일치·NUL 거부), VideoPublicErrors(공개 코드·retryable 사전, BEHAVIOR_NOT_MEASURABLE=false).
  • schemaVersion=1, data=behavior-v1, model=mp-face-pose-v1, thresholds=behavior-policy-v1 고정. 숫자 문자열·문자열 boolean·정수 자리 실수·NaN 거부.
  • 콜백·작업 GET 복구·저장 결과 재조회가 같은 검증기를 쓴다.

C. Web 응답

  • reasonCode/error/behavior/latestAnalysisId/behaviorAnalysisId/showingPreviousResult 추가(null도 키 유지). 최신 요청 상태와 같은 영상의 최신 유효 ready/partial을 따로 고른다.
  • READY/PARTIAL인데 읽을 수 없는 옛 결과는 RESULT_SCHEMA_UNSUPPORTED. canRetry는 PARTIAL/FAILED에서만 true.

D. OFF·재활성화

  • sweep() 3단계 분리: 신규 요청·자동 재분석은 ON일 때만, 기존 PENDING의 같은 requestId 재전송·job GET은 항상(OFF인데 요청 토큰도 없으면 실패로 닫지 않고 미룸).
  • autoReanalysisPending에 enabled 반영(OFF면 FAILED·canRetry=false). 요청 없는 OFF 영상은 NONE/SERVICE_DISABLED.
  • cutoff는 요청 이력이 없는 영상에만 적용 → 재활성화 때 cutoff를 옮겨도 기존 대상의 retry는 막지 않는다.

E. 멱등성·경쟁·수신 한도

  • 콜백·작업 조회는 요청 행 SELECT … FOR UPDATE 뒤 반영. 같은 종료 문서(키 순서·숫자 표기 차이 포함)는 no-op, 상충 문서는 첫 결과 유지 + INVALID_BODY로 별도 보관.
  • failed 원격 문서도 result에 남겨 로컬로만 닫은 행(result 없음)과 구분 → 로컬 FAILED 행에는 늦은 첫 결과를 저장. 호환용 result 응답에는 완료 문서만.
  • 202 acknowledge는 기존대로 PENDING 조건부라 콜백 선도착 뒤 jobId를 덮지 않음(테스트 추가).
  • 요청 생성 경로(자동 첫 요청·자동 재분석·사용자 retry)는 면접 행 잠금 transaction 안에서 자격·최종 영상·PENDING·사용자 chain 한도를 다시 검사. 외부 HTTP는 잠금 밖(sweep).
  • 콜백 본문: 인증 인터셉터 뒤 컨트롤러가 직접 읽음. Content-Length 또는 실제 바이트가 UTF-8 1MiB 초과면 413(저장 안 함), 잘못된 UTF-8은 대체 문자로 별도 보관. 로깅 필터는 영상 경로 본문을 읽거나 로그에 싣지 않고, 길이 모르는(chunked) 본문은 어떤 경로든 버퍼링하지 않음. 별도 보관 저장 실패는 그대로 5xx.

명세에 추가한 기술 가정

  • [가정] 영상 정책 종료 코드: INTERVIEW_ABANDONED, TEXT_INTERVIEW, INTERVIEW_NOT_FOUND(error_retryable=false). 화면에는 나가지 않는다(중단은 409, 텍스트는 NONE).
  • [가정] API 종료 사유의 공개 retryable: NOT_ACKNOWLEDGED/JOB_NOT_FOUND/PROCESSING_OVERDUE=true, INVALID_RESULT/RESULT_EXPIRED=false, 그 밖 내부 코드는 UNKNOWN_ERROR.
  • [가정] 재활성화 절차: 장애 복구 재기동은 VIDEO_ANALYSIS_UPLOADED_AFTER 유지, 의도적 OFF 뒤 재활성화는 재활성화 시각으로 갱신 → OFF 기간 무요청 업로드는 신규 전송 차단, 요청 이력 있는 영상의 retry는 유지. (application.yaml 주석 반영)
  • [가정] 답변 방식이 비어 있는 옛 면접은 음성 면접으로 본다.
  • [가정] eventsTruncated=true면 이벤트가 정확히 60개여야 한다(Analysis 집계 동작·Web 파서와 같음).
  • 구 송신자: 현재 Chat main(c1479ec)은 세션 확정 상태를 실어 보낸다. 상태 없는 옛 outbox payload는 새 면접이면 422로 남으므로 배포 전 Chat outbox에 status 누락 건이 있는지 확인 필요. 과거 fallback으로 COMPLETED가 된 행은 API DB에 표식이 없어 식별 불가 — 일괄 승격·강등하지 않았다.

실행 결과

  • ./gradlew test (격리 PostgreSQL 18 컨테이너, CI와 같은 env): 1092 tests, 0 failures, 0 skipped.
  • 신규/변경 테스트: BehaviorContractTest 69(fixture 9 + 행동 부정 32 + 포장 부정 22 + 기타), VideoAnalysisViewContractTest 9, VideoAnalysisPersistenceTest 28, VideoAnalysisServiceTest 45, InterviewResultIdempotencyTest 8, 컨트롤러·로깅·역직렬화 추가.
  • 정적 분석·별도 lint 설정은 저장소에 없어 실행하지 않음.

PostgreSQL 경쟁 증거 (실제 두 연결)

  • 두_연결이_상충하는_결과를_겹쳐_보내도_먼저_잠근_쪽만_반영한다: 연결 A가 요청 행을 잠근 채 ready 저장, 연결 B의 failed 콜백이 500ms 이상 대기(isNotDone) → A 커밋 뒤 B는 CONFLICT, 행은 READY, 별도 보관 1건.
  • 재분석_한도는_면접_잠금_아래에서_다시_세어_인덱스가_못_잡는_경쟁도_막는다: 탭 A가 면접 행을 잠근 채 두 번째 USER 묶음(종료 상태, PENDING 아님)을 만드는 동안 탭 B retry가 대기 → B는 "2번까지" 409. 부분 unique index로는 잡히지 않는 경우.
  • 두_탭이_동시에_재분석을_눌러도_한_번만_받는다, 완료와_중단이_동시에_와도_먼저_확정한_상태만_남는다(4연결 동시 저장).

Web 계약 호환

  • API GET 응답이 Web #189 api-*.json 9개와 의미값까지 일치.
  • PR #189 validateVideoAnalysis.ts 원본(import 확장자만 추가)을 Node 24로 실행: API 응답 9/9 통과, 부정 변형 12/12 거부.
  • Web 확인 요청: LOW_*_COVERAGE 경고를 Web은 반올림 coverage로, Analysis/API는 원 비율로 판정 — 0.8 경계 5e-7 이내에서 정상 결과를 Web이 거부할 수 있음.

실제 연동 여부

  • 실제 Analysis·S3·Chat·브라우저와 연동하지 않았다. Analysis 호출은 mock, S3 서명은 로컬 계산만. 스테이징·배포 미실행.

남은 미확정·활성화 차단 항목

  • D5 원본 삭제: 삭제 트리거·실패 재개·실제 객체 존재 확인·분석 중 사용권 미확정 → 구현하지 않음. canRetry의 "원본 존재"는 현재 업로드 89일 기준뿐이며 presigned URL 생성 성공을 존재 증거로 쓰지 않는다.
  • OFF 기간 무요청 업로드의 신규 대상 여부: 정책 미확정. 재활성화 시 cutoff 갱신으로 차단하는 운영 절차에 의존.
  • 프록시 상한: 저장소에 프록시 설정이 없다. 앞단 프록시/ALB의 본문 상한(≥1MiB 콜백 허용) 설정은 인프라 담당 확인 필요.
  • 범위 밖(기존 명세 담당과 조율): 텍스트 피드백 단건 조회 409·목록 제외, 음성 분석 경로의 완료 자격·중단 PENDING 정리, dispatch SKIPPED. 공통 정책 객체는 재사용 가능하게 두었다.
  • 실제 모델·실브라우저 영상·30/60분 처리·사람 주석 평가는 Analysis/공동 gate 항목.

이 PR은 검토용이며 병합·배포·운영 데이터 정리 승인을 뜻하지 않는다.

🤖 Generated with Claude Code

kimwuju and others added 3 commits October 20, 2026 21:30
- saveInterview의 null/IN_PROGRESS → COMPLETED 보정을 없앴다. 새 기록의 잘못된 상태는 claim 전에 422로 거절해 면접은 진행 중으로 남는다.
- 이미 종료된 면접은 다시 온 요청의 상태와 상관없이 첫 기록·질문 PK·최종 상태를 그대로 둔다.
- handleResult는 저장 커밋 뒤 DB 상태를 다시 읽어 COMPLETED만 채점·분석을 준비한다. 중단 기록은 보존하고 정상 응답한다.
- 공통 정책 객체 InterviewFeedbackPolicy를 두고 FeedbackService도 같은 판단을 쓴다.
- 모르는 상태 문자열은 400 대신 null로 받아 422로 거절한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 분석 서버 validation.py와 같은 규칙으로 BehaviorData를 타입·의미까지 검증한다(비율·coverage·사유·timeline 합·이벤트·경고·품질 판정).
- 종료 문서 포장 상태 일치, 공개 오류 코드·retryable 사전, videoId·durationMs 묶음, NUL 문자를 검증한다.
- Web PR #189의 계약 fixture 18개를 테스트 리소스로 가져와 정상·부정 변형을 검사한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 새 요청·재전송·작업 조회 복구·자동/사용자 재분석·조회·늦은 콜백이 모두 COMPLETED 음성 면접만 통과한다. 중단·텍스트는 정책 사유로 닫고 진행 중은 기다린다.
- GET/retry 응답에 reasonCode·error·behavior·latestAnalysisId·behaviorAnalysisId·showingPreviousResult를 더했다. 상태와 표시 결과를 따로 고른다.
- sweep을 3단계로 나눠 꺼져 있어도 맡긴 요청의 재전송·조회는 계속한다. 꺼져 있으면 자동 재분석 대기로 보이지 않는다.
- 콜백·작업 조회는 요청 행을 잠그고 같은 검증기를 쓴다. 같은 문서는 no-op, 상충 문서는 처음 것을 두고 따로 남기며, 결과 없이 닫은 행의 늦은 첫 결과는 받는다.
- 요청 생성 경로는 면접 행 잠금 아래에서 자격·진행 중 요청·사용자 한도를 다시 본다.
- 콜백 본문은 인증 뒤 UTF-8 1MiB까지만 읽고 넘으면 413이다. 영상 경로 본문은 로그에 싣지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@2-dayeon

Copy link
Copy Markdown
Contributor

[P2] 이벤트 필드가 null이면 검증기가 NPE로 종료됩니다.
BehaviorValidator.java:157–160
type 또는 sourceMetric이 null이면 불변 Map의 containsKey/containsValue에서 NullPointerException이 발생합니다. 핸들러는 VideoResultException만 처리하므로, 잘못된 콜백이 별도 보관되지 않고 500으로 끝납니다. 문자열 타입을 먼저 검사하고 null 회귀 테스트를 추가해 주세요. 정상 fixture를 변형해 직접 재현했습니다.

@2-dayeon

Copy link
Copy Markdown
Contributor

[P2] 작업 조회 복구 경로에서는 행동 지표가 로그에 노출됩니다.
RequestLoggingFilter.java:63
이번 제외 설정은 인바운드 요청에만 적용됩니다. GET /analysis/video/jobs/{jobId}의 외부 응답은 기존 ExternalApiLoggingInterceptor가 INFO로 기록합니다. 기본 설정에서 얼굴·머리 방향 등의 실제 수치가 로그에 남는 것을 확인했습니다. 외부 영상 분석 경로에도 본문 로깅 제외가 필요합니다.

@danjee2009 danjee2009 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

검토에서 확인한 문제 1건입니다. 유효하지 않은 분석 결과가 영구 오류로 표시되지만 재분석 가능 상태로 저장될 수 있습니다.

… 불일치

- 이벤트 type·sourceMetric이 null이면 불변 Map 조회에서 NPE로 500이 됐다. 문자열을 먼저 검사하고, 규칙이 못 다룬 모양도 계약 위반으로 돌려 따로 남긴다.
- 외부 호출 로그가 /analysis/video 요청·작업 조회 응답 본문(행동 수치)을 남겼다. 이 경로는 본문을 생략한다.
- INVALID_RESULT를 retryable=false로 남긴다. 화면의 error.retryable과 canRetry가 같은 판단(VideoPublicErrors.userRetryable)을 쓴다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kimwuju
kimwuju merged commit 016e3fe into main Oct 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants