● 최종 갱신 2026-09-28 — 해커톤 제출(09-20) 뒤 코드 동결 상태(main 8071fd9) 기준 최종판
온프레미스(학원 PC 3대 · arda.suvisdev.cloud · Cloudflare Tunnel · 로컬 Qwen)는 2026-09-18 폐쇄됐다. 심사·운영은 클라우드 seuk.suvisdev.cloud(API api.seuk.suvisdev.cloud · AWS t3.large · 컨테이너 5개 api·db·caddy·n8n·lie-detection)에서 돌고, 판단 LLM 은 Anthropic Claude, STT 는 두 갈래다 — 업로드 답변·재전사·아르 음성 = OpenAI whisper-1 API, 실시간 면접 전사 = CPU 로컬 faster-whisper (09-17 운영 실측 STT_BACKEND 빈 값 · ADR-0038 「같은 변수, 두 해석」 · 09-18 이후 .env 는 미확인). 자체 학습 Qwen 어댑터는 시연 경로에서 빠졌고 연구·비교 자료로만 남는다. 09-20 이후 커밋·PR 0건 · 09-28 기준 전 컨테이너 정상 가동. 본문 중 온프레미스/자체 서빙/"Claude API 미의존" 서술은 당시 기록이다. (09-18 「클라우드 전환」 안내를 이 칸으로 합쳤다.)
Seuk-Team · Arda ATS · 2026-09-17 갱신판

Arda 기술 해부 노트

09-10 오전 해부노트에 오후~저녁 인터뷰방 대공사를 반영한 갱신판. 이 문서는 새로 만들어지거나 크게 바뀐 기술만 다룬다. 오전 노트의 나머지 기술(FastAPI · SQLAlchemy · alembic · PostgreSQL+pgvector · JWT · S3 · 메일 파이프라인 · 해시 사슬 · 인프라 · 클라이언트 등)은 그대로 유효하다. 여기에는 오늘 오후 이후 코드가 바뀐 자리만 정리한다.

저장소 Seuk-Team/Arda서비스 seuk.suvisdev.cloud오늘 머지 PR 12건
2026-09-17 갱신 · 이 문서(09-10 저녁 인터뷰방 기록)를 최신으로 맞춘 요점
① 헥사고날 부분 적용 완결 — ADR-0035 · PR #184~#209 · 5개 Bounded Context(application·interview·hiring·talent·shared) + 포트/어댑터. Full DDD 아님. 이관 직후 전수 점검에서 테스트는 전부 초록인데 실제로 깨져 있던 고장 11건을 찾아 고쳤다(배포 진입점·함수 안 import·로그 경로가 사각지대). 상세는 자매 문서 「아키텍처 리팩터 보고서 2026-09-17」.
② 클라우드 서빙 확정 — 판단 엔진은 AWS 위에서 Anthropic Claude 로 서빙한다. 서류 요약·평가·추천(3단 체인) · 아르 에이전트 도구 호출 · 지원자/담당자 FAQ 를 모두 Claude 가 담당한다. 사내에서 학습했던 Qwen3 어댑터(agent v9 등)는 완결된 R&D 자산으로 남는다. 아래 본문의 "Claude Haiku 콜당 비용" 류 서술은 작성 시점 기록이다.
③ STT 경로 — ADR-0038 은 실시간 전사도 OpenAI whisper-1 API 로 보내는 설계다. 09-28 정정 다만 09-17 운영 실측에서 STT_BACKEND 가 빈 값이라 업로드 답변·재전사·아르 음성 = whisper-1 API, 실시간 면접 전사 = CPU 로컬 faster-whisper 로 갈려 돌았다(ADR-0038 「같은 변수, 두 해석」 · 09-18 이후 .env 미확인). §4 의 CPU 180초 상한 논리는 그래서 실시간 경로에 그대로 걸려 있었다. API 경로의 무음 hallucination 은 no_speech_prob 게이트로 완화했다. §4 참조.
④ 결정 — ADR-0036 SQS 워커 폐기 · ADR-0037 회사 통합 API · ADR-0038 실시간 전사. 규모(09-17 실측): 백엔드 테스트 1072개 · API 107개 · DB 테이블 28개 · ADR 0001~0038 · 최신 PR #328.
● 2026-09-28 최종 — 동결 시점(main 8071fd9 · 마지막 PR #354) 기준으로 이 노트에 걸리는 변화
① 비용 절감 장치 — 아르의 인사·능력·사용법 발화(#334)와 지원자 FAQ 의 기본·경계 질문(#347)은 캔드 응답으로 LLM 을 우회한다($0). 지원자 FAQ 는 회사 프로필도 근거로 답한다(#348). 무관 질문 거절은 '데이터 근거' 기준으로 명확화(#353). 심사 기간 실측: 아르 누적 62턴 · 누적 비용 $0.72(09-09~09-28).
② §6 불합격 사유 UI 의 자매 기능 — 자료 부족(요약 insufficient) 지원자도 자동 서류 탈락(#349, ADR-0034 확장)하고, 자동 탈락은 screening 단계를 거쳐 화면에 "서류 탈락"으로 뜬다(#351). 탈락 축(서류/면접/종합)은 빨간색으로 표시(#328). 안내 메일은 기존대로 마감 뒤 일괄.
③ §5 세션 · 면접방 — 세션 생성 때 면접 링크 메일이 함께 발행된다(#340). 심사위원 데모 지원자는 로그인마다 인적성·면접일정·AI면접이 초기화된다(#338·#342).
④ §8 ADR-0028 §1.7 세 갈래는 코드 동결(09-20)까지 구현되지 않았다 — 문서상 방향으로 남는다(§8 참조).
⑤ 09-20 이후 커밋·PR 0건. 아래 본문은 동결 상태 그대로 읽으면 된다.
2026-09-12 · 이 문서의 코드 경로가 바뀌었다
ADR-0035 헥사고날 부분 적용(PR #184~#209)으로 backend/app/ 이 Bounded Context 폴더로 재편됐다. 옛 경로로 import 하면 ModuleNotFoundError 가 난다. 아래 본문의 파일 경로는 작성 시점 기록으로 읽고, 실제 위치는 이 표로 환산한다:
app/api/interviews.py → app/interview/api/interviews.py · app/api/internal.py → app/shared/api/internal.py · app/api/agent.py → app/application/api/agent.py · app/screening.py → app/application/screening.py · app/mail.py → app/shared/mail.py · app/models.py → app/models/(6파일 · import 경로는 그대로)
또한 면접 세션 로직 일부가 서비스로 분리됐다: _seed_questions_bg·_generate_followup_bg → app/interview/session_service.py, 일정 후보·제안 → app/interview/schedule_service.py. 동작 변경(09-12): 사전 질문 10개 → 4개(MAX_SEED_QUESTIONS) · 꼬리질문 최대 3개(MAX_FOLLOWUPS_PER_SESSION, 이전엔 상한 없음) · 세션 삭제 시 그 면접방 WebSocket 을 닫고 이유를 통보(이전엔 좀비로 남았다) · 꼬리질문 생성 상한 1200→2400 + 잘린 응답에서 완성된 주장 건지기. 전체는 바탕화면 백엔드_구조_이관_및_점검_2026-09-12.md C부.

0. 오늘 저녁 · 한 눈에PR 12개가 하루 오후~저녁에 · 인터뷰방을 시연 가능한 완성도로

서사 — 아침엔 fit-check 24명이 옛 자소서로 접수돼 있었다. 저녁까지 인터뷰 흐름 5개 축이 모두 자동화 · 표정 · 판정 근거 를 갖추게 했다. 그 사이 배포 파이프라인이 며칠째 죽어 있던 것을 디스크 확장 + torch CPU-only 두 갈래로 되살렸고, ADR-0028 무결성 원장을 실제로 한 번 뚫었다가 "우회의 흔적을 남긴다" 절을 문서에 새로 얹었다.
PR바뀐 파일한 줄화면 결과
#161mobile/lib/screens/applicant_shell.dart재발급 세션에서 새 것부터 pick — reversed 로 훑는다어제 사고("담당자 새 방인데 앱은 옛 방") 재발 방지
#162ai/lie-detection/Dockerfile + infra/docker-compose.prod.yml + backend/.env.exampleViT 활성화 — VIT_MODEL=cloverky/arda-expression-vit + torch 계열 설치프로덕션에 ViT 로드됨
#163backend/app/api/interviews.py, agent/interview_probe.py, alembic 0017답변 뒤 백그라운드로 꼬리질문 하나 더담당자 화면에 🤖 자동 배지
#165ai/lie-detection/feature_extractor.py, interview_ws.py, app.py + 프론트 useLiveAnalysis.ts, InterviewRoom.tsx표정 top-3 를 실시간 분석 판넬에 렌더판정 옆 "우리 ViT · 판정엔 안 들어감" 블록
#166backend/app/api/interviews.py세션 생성 시 자동 질문 10개 훅담당자 질문 입력 필요 없음
#167프론트 ApplicantPanel.tsx, types.ts, module.css자동 불합격 사유 블록아르 요약 아래 판정 근거
#168backend/app/api/interviews.py + 프론트세션 삭제 API + 버튼담당자가 옛 세션 정리
#169ai/lie-detection/requirements-vit.txttorch CPU-only 인덱스이미지 5GB 감소
#170ai/lie-detection/requirements-vit.txttorchvision +cpu pin 해제 (yanked 회피)빌드 성공
#171backend/app/api/interviews.py자동 질문 도중 세션 삭제 → 조용히 취소500 대신 로그만
#172ai/lie-detection/interview_ws.pySTT 상한 45s → 180s3분 답변 텍스트 저장
#173docs/03_decision/0028-제출물-무결성-앵커.mdADR §1.7단계 — 우회의 흔적을 남기는 세 갈래—

1. 세션 생성 시 자동 질문 훅담당자가 매번 텍스트로 넣던 자리를 없앤다

신규_seed_questions_bg — 자소서·이력서·공고에서 꼬리질문 10개

세션 생성 API 응답을 지연시키지 않고 뒤에서 도는 BackgroundTask. LLM 3~5초, 그 사이 담당자는 이미 링크 복사 화면.

왜 필요
ADR-0026 §5-5 지시서에 자동 질문 생성이 설계돼 있었지만, 그동안은 담당자가 손으로 텍스트에어리어에 넣어야 지원자가 시작할 수 있었다. Daniel Kim · 류성민 처럼 자소서가 이미 있는 지원자에게 담당자가 매번 같은 작업을 반복.
어디에
backend/app/api/interviews.py · _seed_questions_bg(session_id) · create_session 이 background.add_task(_seed_questions_bg, session.id) 로 훅.
코드로
backend/app/api/interviews.py — 흐름 요약def _seed_questions_bg(session_id: int) -> None:
    with SessionLocal() as db:
        session = db.get(InterviewSession, session_id)
        if session is None: return
        existing = db.scalar(select(InterviewTurn).where(InterviewTurn.session_id == session_id))
        if existing is not None: return       # 담당자가 수동으로 먼저 넣었으면 존중

        application = db.get(Application, session.application_id)
        sources = sources_of(application, db)  # 자소서 · 이력서 · 공고 요건 재료
        claims = generate_probes(sources)      # LLM 이 5주장 × 2질문 뽑는다

        questions = []
        if claims:
            # 주장별 첫 질문을 먼저 · 그다음 둘째 (같은 주장 질문이 붙지 않게 재조립)
            for i in range(2):
                for c in claims:
                    qs = c.get("questions") or []
                    if i < len(qs): questions.append(qs[i].strip())
            questions = questions[:10]

        if not questions:
            questions = ["성함과 지원하신 직무를 말씀해 주세요.",
                         "가장 자신 있는 기술 하나만 말씀해 주세요.",
                         "입사하면 가장 먼저 하고 싶은 일은 무엇인가요?"]

        if db.get(InterviewSession, session_id) is None:  # #171: 그 사이 삭제됐으면 조용히
            return
        for seq, q in enumerate(questions, start=1):
            db.add(InterviewTurn(session_id=session_id, seq=seq, question=q))
        try: db.commit()
        except Exception: db.rollback(); return
정책
  • 주장별 첫 질문을 먼저 뽑아 다양성 확보. 같은 주장의 두 질문이 붙어 나가면 지원자가 지루하다.
  • 담당자가 이미 넣은 세션은 건드리지 않음 (existing 검사).
  • 폴백 3개 는 어느 지원자에게든 물을 수 있는 안전한 첫 세 개.
  • 실패해도 무해 — 로그만 남기고 세션은 그대로 (담당자가 수동으로 넣을 수 있음).
실측
Daniel Kim 세션 49: 8개 질문 자동 생성. generate_probes 가 이력서의 asyncio·WebSocket·JWT·pytest 커버리지 클레임을 잡아 각각 꼬리질문화. LLM 응답 시간 약 4초.
주의
#171 로 해결된 레이스 — LLM 이 도는 사이 담당자가 세션을 삭제하면 INSERT 가 FK 위반. 지금은 재확인 + try/except 로 조용히 취소.

2. 답변 뒤 꼬리질문 · 비동기 삽입지원자 대기 0초. 다음 질문 답변 중에 뒷 자리에 삽입

신규_generate_followup_bg — 답변 저장 뒤 백그라운드로 하나 더

답변 저장 직후 백그라운드 태스크. LLM 5~8초 걸리는 동안 지원자는 이미 미리 준비된 다음 질문 답변 중이라 지연 노출 0.

왜 필요
실시간 꼬리질문을 답변 완료 직후 동기 로 만들면 지원자가 8~12초 대기(STT 3~6초 + LLM 5초). 팀장 결정 — 비동기 삽입식 으로 자연스러운 흐름.
어디에
backend/app/agent/interview_probe.py · probe_from_answer(prev_question, prev_answer, applicant_summary) · backend/app/api/interviews.py submit_answer 가 백그라운드로 호출.
alembic
0017_followup_questions.py — interview_turns.generated_from_turn_id BIGINT NULL · self FK, ondelete=SET NULL.
코드로
backend/app/agent/interview_probe.py:probe_from_answerFOLLOWUP_MIN_ANSWER_CHARS = 20   # 짧은 답변은 억지로 꼬리 뽑지 않는다
FOLLOWUP_MAX_TOKENS = 200

def probe_from_answer(prev_question, prev_answer, applicant_summary="") -> str | None:
    if len(prev_answer.strip()) < FOLLOWUP_MIN_ANSWER_CHARS:
        return None
    backend = get_summary_backend()
    if backend.unavailable_reason(): return None

    prompt = (
        "면접 진행자다. 방금 지원자가 답한 것을 재료로 **한 문장 짜리 꼬리 질문**을 만든다.\n"
        "- 답변에서 지원자가 실제로 한 말을 근거로 삼는다\n"
        "- 추궁이 아니라 지원자가 세부를 펼칠 자리를 만든다\n"
        "- 60자 이내 · 물음표로 끝난다\n"
        "- 감상·다짐만 있어 물을 세부가 없으면 `SKIP`\n\n"
        f"[지원자 요약]\n{applicant_summary}\n\n[직전 질문]\n{prev_question}\n\n[지원자 답변]\n{prev_answer}\n\n[꼬리 질문]"
    )
    result = backend.complete(prompt=prompt, max_tokens=FOLLOWUP_MAX_TOKENS)
    text = result.text.strip()
    # ```..``` · 앞뒤 따옴표 벗기기 · SKIP 처리 · 250자 초과 폐기
    ...
    return text or None
DB 삽입 · seq = max+1 로 뒤에 붙는다max_seq = (db.query(InterviewTurn.seq).filter(InterviewTurn.session_id == session_id)
             .order_by(InterviewTurn.seq.desc()).limit(1).scalar() or 0)
db.add(InterviewTurn(session_id=session_id, seq=max_seq + 1,
                     question=question,
                     generated_from_turn_id=prev_turn_id))
UI 표시
frontend/app/src/pages/InterviewWatch.tsx 가 generated_from_turn_id != null 이면 🤖 배지. 담당자가 자동 생성인지 바로 판독.
정책
  • 20자 미만 답변은 즉시 None. "파이썬입니다" 같은 짧은 답변에 억지 꼬리 붙이면 면접관에게 해롭다 (interview_probe.py 원본 정책 그대로).
  • SKIP · 감상만 있으면 뽑지 않는다 — LLM 이 SKIP 을 반환하면 취소.
  • 250자 초과는 다듬어도 못 살림. 프롬프트를 뚫고 큰 응답이 온 경우.
비용
09-18 갱신 꼬리질문 생성은 클라우드 Anthropic Claude 가 서빙한다 — 09-11 실측 기준 콜당 ~2.5원 · 면접당 5~8콜 → 15~20원/면접. (캔드 응답 $0 은 아르·지원자 FAQ 의 기본·경계 질문 쪽 장치다 — 꼬리질문과는 별개.) 사내에서 학습했던 Qwen 어댑터(agent·summary·interview)는 09-28 온프레미스 폐쇄(09-18)와 함께 시연 경로에서 빠졌고 연구·비교 자료로만 남는다.

3. ViT 표정 실시간 파이프라인얼굴 → 크롭 → ViT → top-3 → WS → UI

신규expressions_from_frame — 프레임 하나로 표정 top-K

한 장 BGR 프레임에서 얼굴을 검출하고 크롭하고 ViT 로 표정 확률을 뽑아 top-K 리스트를 돌려준다. VIT_MODEL 이 꺼져 있으면 None (무해).

어디에
ai/lie-detection/feature_extractor.py · 새 함수 expressions_from_frame(frame, top_k=3). 라벨은 모델의 id2label(영어) + 담당자용 한국어 매핑.
코드로
feature_extractor.py — 한국어 라벨 매핑_EXPRESSION_KO = {
    "angry":"화남", "disgust":"역겨움", "fear":"무서움",
    "happy":"웃음", "sad":"슬픔", "surprise":"놀람", "neutral":"무표정",
}

def expressions_from_frame(frame, top_k: int = 3) -> list[dict] | None:
    if not VIT_MODEL: return None
    lm, rgb = _detect(frame)             # mediapipe · 얼굴 랜드마크
    if lm is None: return None
    crop = _crop_of(lm, rgb)             # 랜드마크 덮는 사각형만
    if crop is None: return None         # < 60px 면 폐기
    probs = expression_rows([crop])      # ViT 로 7종 확률
    if not len(probs): return None
    labels = _vit_labels()               # id2label
    row = probs[0]
    order = np.argsort(row)[::-1][:top_k]
    return [{"label": labels[int(i)],
             "label_ko": _EXPRESSION_KO.get(labels[int(i)].lower(), labels[int(i)]),
             "prob": round(float(row[i]), 3)} for i in order]
interview_ws.py:LiveScorer — 최근 프레임 하나 캐시class LiveScorer:
    def __init__(self, ...):
        ...
        self._latest_frame = None       # ViT 판정용 · 한 장만
    def add_frame(self, bgr) -> None:
        self._latest_frame = bgr        # 얼굴 잡힐 때마다 갱신
    def snapshot(self):
        return bytes(self._pcm), [r for _, r in self._rows], self._latest_frame
interview_ws.py:score — 판정 옆에 표정 붙임def score(pcm, rows, seconds, latest_frame=None) -> dict:
    ...
    # 표정: 프레임이 있고 VIT_MODEL 이 켜져 있을 때만
    expressions = None
    if latest_frame is not None:
        try:    expressions = expressions_from_frame(latest_frame, top_k=3)
        except: logger.exception("표정 판정 실패 — 판정은 그대로 낸다")

    out = {"ok": True, "pred": int(...), "truth_pct": ..., "lie_pct": ...,
           "signals": face_signals(arr, seconds)}
    if expressions: out["expressions"] = expressions
    return out
프론트 렌더
frontend/app/src/pages/useLiveAnalysis.ts — 파싱export type Expression = { label: string; label_ko: string; prob: number }
export type LiveVerdict = {
  truth_pct?: number; lie_pct?: number;
  signals?: FaceSignal[];
  expressions?: Expression[];       // 신규
  reason?: string; at: number;
}
// onmessage 에서: expressions: Array.isArray(m.expressions) ? m.expressions : undefined,
InterviewRoom.tsx — 판정 옆에 top-3 블록{analysis.latest.expressions?.length ? (
  <div className={styles.expressions}>
    <p className={styles.expressionsLabel}>표정 (우리 ViT · 판정엔 안 들어감)</p>
    <ul className={styles.expressionsList}>
      {analysis.latest.expressions.slice(0, 3).map((ex) => (
        <li key={ex.label} className={styles.expressionRow}>
          <span className={styles.expressionName}>{ex.label_ko || ex.label}</span>
          <span className={styles.expressionPct}>{Math.round(ex.prob * 100)}%</span>
        </li>
      ))}
    </ul>
  </div>
) : null}
왜 판정엔 안 들어가나
model.pkl 이 100차원 (음성 86 + mediapipe 14) 으로 학습됐다. 표정 7개 확률을 그대로 넣으면 shape mismatch 로 즉시 죽고, 억지로 평균 등으로 낮추면 잘못된 점수. 그래서 화면 옆에 별도 로만 둔다(ADR-0029/0032). 09-17 게다가 AI 채용 정책상 감정 인식 신호를 판정 입력으로 쓰지 않는다 — 표정·음성 신호를 판정/Claude 입력에 넣지 않는 것이 원칙이라, "재학습 뒤 판정 벡터로 이관"은 이 제약 아래에서만 검토 대상이었고, 코드 동결(09-20)까지 실행되지 않았다. 표정은 어디까지나 담당자 참고용 UI.
실측
ViT 로드 확인: docker exec arda-lie-detection-1 python -c 'model = AutoModelForImageClassification.from_pretrained(VIT_MODEL); print(model.config.id2label)' → 7종 라벨 정상 반환. 파이프라인 자체는 얼굴 검출 되어야 표정 나옴 — no_face 면 UI 에 표정 없이 판정만.
주의
표정 UI 는 채용자 화면 (InterviewRoom.tsx) 에만. APK (지원자 자리) 에는 표시 안 함 — ADR-0029 "지원자에게 실시간 판정을 보여주면 답변을 바꾼다".

4. STT 상한 45s → 180s · 3분 답변 커버faster-whisper CPU 실측 1.8배속 기준 재계산

개정STT_TIMEOUT_SEC · 계산 근거 재수립

기존 45초 상한이 "int8 4배속 가정" 이었지만 실측은 1.8배속. Daniel Kim 98초·63.8초 답변이 자리표시자로 저장되던 것을 실측 뒤 180초로 상향.

왜 바꿨나
실측 DB (2026-09-10 저녁):
| seq | question | 저장된 답변                |
|-----|----------|-----------------------------|
|   1 | 3k 동시…  | [전사 지연 · 발화 98.0초]  |  ← 상한 초과
|   2 | disconnect… | [전사 지연 · 발화 63.8초] |  ← 상한 초과
CPU int8 실측 1.8배속 (ADR-0032 §근거) 이므로 3분 발화 = 100초 처리. 상한 45초는 첫 3분 답변에도 못 미친다. 면접은 3분 이상 이야기하는 자리(팀장 지적).
어디에
ai/lie-detection/interview_ws.py:836 · STT_TIMEOUT_SEC = float(os.getenv("STT_TIMEOUT_SEC", "180")). env override 그대로.
코드로
interview_ws.py — 계산 근거 주석과 함께# **면접 답변은 3분까지 갈 수 있다** (2026-09-10 실측). CPU int8 실측 속도(1.8배속)
# 로 3분 발화가 100초 안에 처리되고, 여기에 여유 80초를 더한 180초로 상향한다.
# 이 시간에 다음 질문은 이미 나가 있으므로 지원자를 대기시키지 않는다.
#
# GPU 로 옮기면 30~50배속이라 3분 답변도 ~5초에 끝난다. 그때는 상한을 다시
# 45초로 되돌려도 된다 — 짧게 두면 STT 스레드가 CPU 자원을 오래 잡지 않는다.
STT_TIMEOUT_SEC = float(os.getenv("STT_TIMEOUT_SEC", "180"))
무해한 이유
전사는 별도 스레드. 면접 흐름은 다음 질문 이미 받아 뒀으므로 지원자를 대기시키지 않는다 (submit_answer 흐름 · fetch_questions 로 다음 질문 미리 받음). STT 스레드가 CPU 를 오래 잡는 동안에도 이벤트 루프는 계속 돌아 다음 답변을 받는다.
남는 한계
5분 이상 답변은 CPU 로 여전히 불가. 300초 발화 → 165초 처리, 상한을 어떻게 늘려도 CPU 가 답변 페이스 못 따라감. 동시 지원자 여러 명도 CPU 병목. GPU 만이 진짜 해결 (T4 30배속, 3분 답변 → 6초, 10분 답변 → 20초).
STT 경로 · 설계와 운영 실측 (09-18 갱신 → 09-28 정정)
설계(ADR-0038): 실시간 면접 전사도 OpenAI whisper-1 API 로 보낸다 (분당 $0.006 → 면접당 ~$0.06). 무음 구간 hallucination(빈 소리에 없는 말을 지어냄)은 no_speech_prob 게이트로 완화했다.
운영 실측(09-17): STT_BACKEND 가 빈 값이라 두 컨테이너가 다르게 읽었다 — 백엔드(업로드 답변·재전사·아르 음성) = whisper-1 API, 실시간 서버(lie-detection) = CPU 로컬 faster-whisper (ADR-0038 「같은 변수, 두 해석」). 즉 위 §4 의 CPU int8 180초 상한 논리는 09-17 기준 실시간 면접에 실제로 걸려 있던 경로다. 09-18 이후 .env 값은 확인하지 않았다. (09-18 판은 "whisper-1 API 가 프로덕션 기본" 으로 단정했었다.)
GPU 실측(Tesla T4): faster-whisper large-v3-turbo CUDA fp16 275ms/3s — 로컬 경로를 GPU 에서 돌렸을 때의 참고 수치다.

5. 세션 삭제 API + UI담당자가 옛것 정리할 자리 · 자식 표 수동 CASCADE

신규DELETE /interview-sessions/{id}

상태 무관 삭제. pending · in_progress · done · expired 모두 지울 수 있다. FK 에 ondelete 를 안 걸어 뒀기 때문에 수동 CASCADE 로 자식 표(interview_findings, interview_turns) 를 먼저 지운다.

어디에
backend/app/api/interviews.py · delete_session(session_id, db, user). get_current_user 필수 — 담당자만.
코드로
interviews.py — 순서가 중요@router.delete("/interview-sessions/{session_id}",
               status_code=HTTPStatus.NO_CONTENT)
def delete_session(session_id, db, user):
    from app.models import InterviewFinding
    session = db.get(InterviewSession, session_id)
    if session is None:
        raise HTTPException(HTTPStatus.NOT_FOUND, "면접 세션을 찾을 수 없습니다")

    # 자식 먼저 — FK ondelete 가 없어 순서를 지켜야 한다
    db.execute(InterviewFinding.__table__.delete()
               .where(InterviewFinding.session_id == session_id))
    db.execute(InterviewTurn.__table__.delete()
               .where(InterviewTurn.session_id == session_id))
    db.delete(session)
    db.commit()
프론트
frontend/app/src/api/endpoints.ts · interviews.remove(sessionId). ApplicantPanel.tsx 세션 행 헤더에 "삭제" 버튼 (danger 색). window.confirm 으로 실수 방지 — "세션·질문·답변·findings 가 함께 지워지고 되돌릴 수 없습니다".
정책
  • 상태 제한 없음 — 어제 in_progress 로 남았던 세션 23 스타일도 여기서 정리 가능.
  • 지원자 접속 중이어도 허용 — 방이 닫힐 뿐 되돌릴 수 없는 손해 없음.
  • 확인창 필수 — 자식 다 함께 지워짐 명시.

6. 자동 불합격 사유 UIdoc_score_detail.concerns 를 요약 아래에 노출

신규RejectionReason 컴포넌트

자동 심사(ADR-0034)로 불합격 판정된 지원자에게 판정 근거를 요약 옆에 붙인다. 데이터는 원래 있었지만 화면에 안 나왔던 자리를 뚫었다.

왜 필요
담당자가 지원자 패널을 열면 아르 요약(gist · fit · concerns)만 보이고, 불합격한 경우 왜 떨어졌나 는 다른 자리에서 찾아야 했다. 실제 판정 이유는 doc_score_detail 에 이미 쌓여 있었다.
언제 뜨나
  • current_stage === 'rejected' (실제 불합격)
  • doc_decision === 'reject' (자동 심사 불합격 판정)
  • decision_source === 'agent' (사람이 아니라 아르)
  • doc_score_detail 있음
무엇을 보여주나
  • 🔴 아르 자동 불합격 배지 + 총점 / 기준점 (예: 50점 · 기준 60점)
  • 필수 · 우대 · 인재상 세 축 점수 (담당자가 어느 축이 부족했나 즉시 판독)
  • 불합격 사유 (concerns) 목록
코드로
frontend/app/src/api/types.ts — 백엔드 스키마는 이미 갖고 있었으나 프론트 타입에서 빠져 있었다export interface DocScoreDetail {
  requirements?: number
  preferred?: number
  culture?: number
  fit?: string[]
  concerns?: string[]
  evidence?: string[]
  weights?: Record<string, number>
  threshold?: number
}

export interface ApplicationDetail {
  ...
  doc_score?: number | null
  doc_score_detail?: DocScoreDetail | null
  doc_decision?: 'pass' | 'reject' | 'hold' | null
  decision_source?: 'agent' | 'human' | null
}
ApplicantPanel.tsx — 조건부 렌더{detail.current_stage === 'rejected'
  && detail.doc_decision === 'reject'
  && detail.decision_source === 'agent'
  && detail.doc_score_detail
  && (
    <RejectionReason
      score={detail.doc_score ?? null}
      threshold={detail.doc_score_detail.threshold ?? null}
      detail={detail.doc_score_detail}
    />
  )}
스타일
--danger-soft 배경 · --danger 좌측 테두리로 "결정이 이미 난 자리" 를 색으로 알려 준다. 다른 자리(cover_letter 등)와 달리 여기는 결정 사후 자리라 색이 정보.
예시
한지우 (자동 불합격 50점):
아르의 요약
[요약 · 강점 · 확인 필요]

━━━━━━━━━━━━━━━━━━━━━━━━━
🔴 아르 자동 불합격        50점 · 기준 60점
필수 35 · 우대 50 · 인재상 75

불합격 사유
• Python·FastAPI 실무 경험 없음, React·TypeScript 전문가
• PostgreSQL 등 백엔드 스택 경험 여부 확인 필요
• 우대사항 없어 추가 가산점 기준 부재
━━━━━━━━━━━━━━━━━━━━━━━━━

7. 배포 파이프라인 복구 · torch CPU-only + EBS 50GB디스크 부족으로 며칠 죽어 있던 CD 를 되살리는 과정

개정requirements-vit.txt — CPU-only 인덱스

torch 가 기본 인덱스에서 nvidia-cudnn · nvidia-cublas · triton · cuda-toolkit 등 GPU 런타임 5GB 를 통째로 끌고 와 서버 (29GB 중 8GB 남음) 를 터뜨리던 것을 CPU 전용 인덱스로 잡음.

왜 필요
배포 로그 (실측):
#31 108.9 ERROR: Could not install packages due to an OSError:
       [Errno 28] No space left on device
       (설치 중: nvidia-cusparselt-cu13, cuda-toolkit, triton, sympy, ...,
                torch, torchvision)
결과 #163·#165·#166·#167·#168 이 서버에 며칠째 반영 안 됨 — 프론트만 Vercel 로 자동 배포되어 UI 는 보이는데 백엔드가 옛 코드.
코드로
ai/lie-detection/requirements-vit.txt — CPU-only 인덱스# CPU 서버(t3.large) 에서 도는 자리라 **CPU-only torch** 로 설치한다.
# 기본 (`pip install torch`) 은 CUDA 패키지(nvidia-cudnn·nvidia-cublas 등)를 통째로
# 끌고 와 이미지 · 디스크가 ~5GB 넘게 부풀고, 우리 서버 (29G 중 8G 남음) 에서
# `No space left on device` 로 배포가 계속 실패했다 (2026-09-10 실측).
#
# 아래 `--index-url` 는 CPU 전용 whl 저장소. torch 자체는 ~200MB.
# **버전을 pin 하지 않는다** — CPU 인덱스에 있는 최신을 자동으로 잡는다. 옛 특정
# `+cpu` 태그 (예: torchvision==0.20.1+cpu) 는 게시가 취소된 것들이 있어서 pin 하면
# 오히려 다시 터진다. 지금은 `>=` 만 걸고 pip 에게 결정을 맡긴다.
#
# GPU 서버로 옮길 때는 이 `--extra-index-url` 줄만 지우고 다시 빌드한다.
--extra-index-url https://download.pytorch.org/whl/cpu

torch>=2.5
torchvision>=0.20
transformers>=4.40
pillow>=10.0
겪은 일 (3개 단계)
  • 단계 1 · PR #169 — --extra-index-url 만 추가 후 torch==2.5.1+cpu 로 pin. 실패: torchvision==0.20.1+cpu 가 CPU 인덱스에서 yanked 로 게시 취소.
  • 단계 2 · PR #170 — +cpu pin 해제. 성공. 인덱스에 있는 최신 +cpu 변종 자동 선택.
  • 단계 3 · 사용자 손 — AWS 콘솔에서 EBS 29 → 50GB 확장. growpart /dev/nvme0n1 1 && resize2fs /dev/nvme0n1p1. 확장 후 여유 27GB (44% 사용). 월 $3 추가.
교훈
PyTorch CPU 서버에서는 반드시 CPU whl 인덱스로 명시. 기본 인덱스는 GPU 우선이라 nvidia-* 계열이 끌려 온다. GPU 승격 시엔 이 두 줄만 지우고 CUDA 베이스 이미지로 Dockerfile 재작성.

8. ADR-0028 §1.7단계 · 우회의 흔적을 남긴다PR #173 · 소유자가 뚫어도 자국이 남게

문서ADR-0028 §1.7단계 세 갈래

1.6단계 (권한 분리) + 관리 비밀번호 분리로도 테이블 소유자는 여전히 ALTER TABLE ... DISABLE TRIGGER 로 append-only 잠금을 뚫을 수 있다. 막을 수는 없지만 — 우리 서버니까 — 뚫으면 반드시 자국이 남게 할 수 있다.

왜 필요
1.6단계까지의 한계: 관리 비밀번호를 아는 사람은 여전히 원장을 다시 쓸 수 있다. ADR-0028 서두의 원칙 ("우리는 못 막을 수 있지만, 고친 사실이 밖에서 드러난다") 을 DB 층에서도 반복 적용.
09-28 최종
동결 상태 코드 동결(09-20) 시점에도 세 갈래 모두 코드로 들어가지 않았다. 원장 사슬 검증 함수(anchoring.verify_chain)는 무결성 API 로 조회할 수 있지만, 게시 배치에서 게시를 멈추는 ③ 연결은 동결 코드에 없다.
세 갈래 (구현 미완 · 팀 결정)
  • ① Event trigger 로 트리거 상태 변경을 append-only 감사 로그에 기록 — PostgreSQL 의 DDL 이벤트 트리거(ddl_command_start · ddl_command_end) 를 잡아 ALTER TRIGGER 이벤트를 document_anchors_trigger_audit 에 append. 이 감사 표에도 append-only 트리거를 걸면 우회는 감사 로그 자체까지 지워야 가능해진다.
  • ② document_anchors.tgenabled 를 주기 감시 · 변경 시 알림 — pg_trigger.tgenabled 를 1분마다 확인하는 헬스체크. 'D' (disabled) 로 바뀐 순간 슬랙 · 이메일 · 팀 채널에 알림. 이미 있는 n8n 워크플로 구조로 가볍게.
  • ③ Sepolia 게시 시점에 원장 검증 · 어긋나면 게시 중단 — chain_publications 배치가 돌 때 seq 구멍 · chain_hash 사슬 무결성 확인. 어긋난 것이 있으면 게시 중단 + 알림. 뚫린 상태를 밖으로 못 나가게 한다.
우선순위
③ > ② > ①. ③이 외부 증명 신뢰도를 지키는 값이 크다. ② 는 가장 값싸다 (n8n 헬스 하나). ① 은 DDL 이벤트 트리거 도입이라 검토 필요.
목적
봉인이 아니라 문턱·자국. 셋 다 붙으면 누구도 조용히 못 뚫는다 — 뚫으면 반드시 감사 로그 · 알림 · 게시 중단 셋 중 하나에 자국.

9. 잠긴 원장 뚫기 · 실전 절차 · 뒤처리fit-check 24명 재접수 · 트랜잭션 하나로 처리

실전트리거 임시 해제 → 삭제 → 재활성화 — 트랜잭션 원자성으로 안전

시연 앞두고 fit-check 24명 중 23명을 새 STAR 콘텐츠로 재접수. append-only 트리거가 막고 있어 임시 해제가 필요했다. 실행 스크립트는 사후 삭제.

왜 이 절차
fit-check 지원자들의 옛 자소서를 새 STAR 4문항 형식으로 재접수하려는데, document_anchors 의 append-only 트리거가 DELETE 를 거부. 지원자 삭제도 document_anchors.application_id FK 때문에 실패. 트리거 해제 없이는 불가능.
실행 SQL
wipe_fit_check.sql — 트랜잭션 원자성으로 안전BEGIN;
-- 대상 지원자 id (홍지민 be-05 제외 · 시연 배지 대상으로 유지)
CREATE TEMP TABLE _targets AS
SELECT id FROM applications
WHERE email LIKE 'fitcheck-%@example.com' AND email <> 'fitcheck-be-05@example.com';

-- 자식 표 먼저 (interview_findings · turns · sessions 등 11개)
DELETE FROM interview_findings WHERE session_id IN (
  SELECT id FROM interview_sessions WHERE application_id IN (SELECT id FROM _targets));
-- ... 나머지 9개 자식 표 삭제 ...

-- document_anchors 를 files 보다 먼저 지운다 (document_anchors.file_id FK → files.id)
ALTER TABLE document_anchors DISABLE TRIGGER trg_document_anchors_append_only;
DELETE FROM document_anchors WHERE application_id IN (SELECT id FROM _targets);
ALTER TABLE document_anchors ENABLE TRIGGER trg_document_anchors_append_only;

DELETE FROM files WHERE application_id IN (SELECT id FROM _targets);
DELETE FROM applications WHERE id IN (SELECT id FROM _targets);

-- 검증 (반드시 확인)
SELECT tgname, tgenabled FROM pg_trigger
WHERE tgname LIKE 'trg_document_anchors%';  -- 두 개 다 'O' 여야 함

COMMIT;
안전 장치
  • 트랜잭션 원자성 — BEGIN...COMMIT 사이 어디서든 실패 시 자동 ROLLBACK → 트리거 상태·데이터 다 되돌림.
  • DDL 문도 트랜잭션 안 — PostgreSQL 의 ALTER TABLE 은 트랜잭션 내에서 롤백 가능.
  • 커밋 뒤 검증 필수 — SELECT tgname, tgenabled FROM pg_trigger WHERE tgname='trg_document_anchors_append_only' 로 'O' (ENABLED) 확인.
  • 스크립트 사후 삭제 — rm wipe_fit_check.sql · 서버·컨테이너·로컬 모두. 뚫은 방법이 남아 있으면 다시 뚫릴 수 있다.
재접수
새 submit_new.py 로 STAR 자소서 텍스트 + 새 PDF 를 POST /public/postings/{id}/applications 로. 23명 접수 성공, 홍지민 1명은 --skip fitcheck-be-05@example.com 로 남김.
최종 상태
  • 총 fit-check 지원자: 24명 (23 새것 + 홍지민 1)
  • 홍지민 (id=52): 옛 application · 옛 anchor · 새 S3 PDF → "내용이 바뀜" 배지 시연 대상
  • 새 23명 (id 60~82): 새 application · 새 anchor · 새 S3 → 배지 없음 (정상)
  • 트리거: 두 개 다 ENABLED · DELETE 시도 시 ERROR: 무결성 원장은 삭제할 수 없습니다
  • Sepolia 게시된 앵커 (seq 30 · seq 78) 영향 없음 (우리가 삭제한 것은 seq > 78)
연결
이 사건이 §8 (ADR-0028 §1.7단계) 을 문서에 추가하게 된 계기. "봉인이 아니라 문턱이지만, 뚫으면 자국이 남게" 라는 방향의 근거.
주의 · 실 운영에선 절대 하지 않을 것. 이 절차는 시연 데이터(fit-check applicants) 에 한정. 실 지원자 데이터는 무결성 원장을 그대로 보존해야 한다. 오늘 이 절차를 실행할 수 있었던 것은 이 데이터가 시연용이라는 특수 상황.

근거: 오전 09-10 해부 노트 (변경 없는 기술) · backend/app/api/interviews.py · backend/app/agent/interview_probe.py · ai/lie-detection/feature_extractor.py · ai/lie-detection/interview_ws.py · ai/lie-detection/app.py · ai/lie-detection/requirements-vit.txt · ai/lie-detection/Dockerfile · infra/docker-compose.prod.yml · frontend/app/src/pages/InterviewRoom.tsx · frontend/app/src/pages/InterviewWatch.tsx · frontend/app/src/pages/ApplicantPanel.tsx · frontend/app/src/pages/useLiveAnalysis.ts · frontend/app/src/api/types.ts · frontend/app/src/api/endpoints.ts · backend/alembic/versions/0017_followup_questions.py · docs/03_decision/0028-제출물-무결성-앵커.md · docs/03_decision/0032-추론-모델-3종-확정.md. 작성 2026-09-10 저녁 · 2026-09-17 갱신 → 2026-09-18 클라우드 전환(헥사고날 완결·판단 엔진 클라우드 Anthropic Claude 서빙·STT OpenAI whisper-1 API[09-28 정정: 실시간 전사는 CPU 로컬로 갈려 돌았음]·ADR-0036~0038·규모 수치 반영) → 2026-09-28 최종(온프레미스 폐쇄 · 코드 동결 8071fd9 · 캔드 응답·자료 부족 자동 서류 탈락·면접 링크 메일·데모 리셋 · §1.7 미구현 명시).