STT_BACKEND 빈 값 · ADR-0038 「같은 변수, 두 해석」 · 09-18 이후 .env 는 미확인). 자체 학습 Qwen 어댑터는 시연 경로에서 빠졌고 연구·비교 자료로만 남는다. 09-20 이후 커밋·PR 0건 · 09-28 기준 전 컨테이너 정상 가동. 본문 중 온프레미스/자체 서빙/"Claude API 미의존" 서술은 당시 기록이다. (09-18 「클라우드 전환」 안내를 이 칸으로 합쳤다.)09-10 오전 해부노트에 오후~저녁 인터뷰방 대공사를 반영한 갱신판. 이 문서는 새로 만들어지거나 크게 바뀐 기술만 다룬다. 오전 노트의 나머지 기술(FastAPI · SQLAlchemy · alembic · PostgreSQL+pgvector · JWT · S3 · 메일 파이프라인 · 해시 사슬 · 인프라 · 클라이언트 등)은 그대로 유효하다. 여기에는 오늘 오후 이후 코드가 바뀐 자리만 정리한다.
STT_BACKEND 가 빈 값이라 업로드 답변·재전사·아르 음성 = whisper-1 API, 실시간 면접 전사 = CPU 로컬 faster-whisper 로 갈려 돌았다(ADR-0038 「같은 변수, 두 해석」 · 09-18 이후 .env 미확인). §4 의 CPU 180초 상한 논리는 그래서 실시간 경로에 그대로 걸려 있었다. API 경로의 무음 hallucination 은 no_speech_prob 게이트로 완화했다. §4 참조.8071fd9 · 마지막 PR #354) 기준으로 이 노트에 걸리는 변화insufficient) 지원자도 자동 서류 탈락(#349, ADR-0034 확장)하고, 자동 탈락은 screening 단계를 거쳐 화면에 "서류 탈락"으로 뜬다(#351). 탈락 축(서류/면접/종합)은 빨간색으로 표시(#328). 안내 메일은 기존대로 마감 뒤 일괄.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부.| PR | 바뀐 파일 | 한 줄 | 화면 결과 |
|---|---|---|---|
| #161 | mobile/lib/screens/applicant_shell.dart | 재발급 세션에서 새 것부터 pick — reversed 로 훑는다 | 어제 사고("담당자 새 방인데 앱은 옛 방") 재발 방지 |
| #162 | ai/lie-detection/Dockerfile + infra/docker-compose.prod.yml + backend/.env.example | ViT 활성화 — VIT_MODEL=cloverky/arda-expression-vit + torch 계열 설치 | 프로덕션에 ViT 로드됨 |
| #163 | backend/app/api/interviews.py, agent/interview_probe.py, alembic 0017 | 답변 뒤 백그라운드로 꼬리질문 하나 더 | 담당자 화면에 🤖 자동 배지 |
| #165 | ai/lie-detection/feature_extractor.py, interview_ws.py, app.py + 프론트 useLiveAnalysis.ts, InterviewRoom.tsx | 표정 top-3 를 실시간 분석 판넬에 렌더 | 판정 옆 "우리 ViT · 판정엔 안 들어감" 블록 |
| #166 | backend/app/api/interviews.py | 세션 생성 시 자동 질문 10개 훅 | 담당자 질문 입력 필요 없음 |
| #167 | 프론트 ApplicantPanel.tsx, types.ts, module.css | 자동 불합격 사유 블록 | 아르 요약 아래 판정 근거 |
| #168 | backend/app/api/interviews.py + 프론트 | 세션 삭제 API + 버튼 | 담당자가 옛 세션 정리 |
| #169 | ai/lie-detection/requirements-vit.txt | torch CPU-only 인덱스 | 이미지 5GB 감소 |
| #170 | ai/lie-detection/requirements-vit.txt | torchvision +cpu pin 해제 (yanked 회피) | 빌드 성공 |
| #171 | backend/app/api/interviews.py | 자동 질문 도중 세션 삭제 → 조용히 취소 | 500 대신 로그만 |
| #172 | ai/lie-detection/interview_ws.py | STT 상한 45s → 180s | 3분 답변 텍스트 저장 |
| #173 | docs/03_decision/0028-제출물-무결성-앵커.md | ADR §1.7단계 — 우회의 흔적을 남기는 세 갈래 | — |
세션 생성 API 응답을 지연시키지 않고 뒤에서 도는 BackgroundTask. LLM 3~5초, 그 사이 담당자는 이미 링크 복사 화면.
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(); returngenerate_probes 가 이력서의 asyncio·WebSocket·JWT·pytest 커버리지 클레임을 잡아 각각 꼬리질문화. LLM 응답 시간 약 4초.답변 저장 직후 백그라운드 태스크. LLM 5~8초 걸리는 동안 지원자는 이미 미리 준비된 다음 질문 답변 중이라 지연 노출 0.
backend/app/agent/interview_probe.py · probe_from_answer(prev_question, prev_answer, applicant_summary) · backend/app/api/interviews.py submit_answer 가 백그라운드로 호출.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))frontend/app/src/pages/InterviewWatch.tsx 가 generated_from_turn_id != null 이면 🤖 배지. 담당자가 자동 생성인지 바로 판독.한 장 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 outfrontend/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.docker exec arda-lie-detection-1 python -c 'model = AutoModelForImageClassification.from_pretrained(VIT_MODEL); print(model.config.id2label)' → 7종 라벨 정상 반환. 파이프라인 자체는 얼굴 검출 되어야 표정 나옴 — no_face 면 UI 에 표정 없이 판정만.기존 45초 상한이 "int8 4배속 가정" 이었지만 실측은 1.8배속. Daniel Kim 98초·63.8초 답변이 자리표시자로 저장되던 것을 실측 뒤 180초로 상향.
| 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 를 오래 잡는 동안에도 이벤트 루프는 계속 돌아 다음 답변을 받는다.no_speech_prob 게이트로 완화했다.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 가 프로덕션 기본" 으로 단정했었다.)상태 무관 삭제. 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 가 함께 지워지고 되돌릴 수 없습니다".자동 심사(ADR-0034)로 불합격 판정된 지원자에게 판정 근거를 요약 옆에 붙인다. 데이터는 원래 있었지만 화면에 안 나왔던 자리를 뚫었다.
gist · fit · concerns)만 보이고, 불합격한 경우 왜 떨어졌나 는 다른 자리에서 찾아야 했다. 실제 판정 이유는 doc_score_detail 에 이미 쌓여 있었다.current_stage === 'rejected' (실제 불합격)doc_decision === 'reject' (자동 심사 불합격 판정)decision_source === 'agent' (사람이 아니라 아르)doc_score_detail 있음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점 · 기준 60점
필수 35 · 우대 50 · 인재상 75
불합격 사유
• Python·FastAPI 실무 경험 없음, React·TypeScript 전문가
• PostgreSQL 등 백엔드 스택 경험 여부 확인 필요
• 우대사항 없어 추가 가산점 기준 부재
━━━━━━━━━━━━━━━━━━━━━━━━━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--extra-index-url 만 추가 후 torch==2.5.1+cpu 로 pin. 실패: torchvision==0.20.1+cpu 가 CPU 인덱스에서 yanked 로 게시 취소.+cpu pin 해제. 성공. 인덱스에 있는 최신 +cpu 변종 자동 선택.growpart /dev/nvme0n1 1 && resize2fs /dev/nvme0n1p1. 확장 후 여유 27GB (44% 사용). 월 $3 추가.1.6단계 (권한 분리) + 관리 비밀번호 분리로도 테이블 소유자는 여전히 ALTER TABLE ... DISABLE TRIGGER 로 append-only 잠금을 뚫을 수 있다. 막을 수는 없지만 — 우리 서버니까 — 뚫으면 반드시 자국이 남게 할 수 있다.
anchoring.verify_chain)는 무결성 API 로 조회할 수 있지만, 게시 배치에서 게시를 멈추는 ③ 연결은 동결 코드에 없다.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 워크플로 구조로 가볍게.chain_publications 배치가 돌 때 seq 구멍 · chain_hash 사슬 무결성 확인. 어긋난 것이 있으면 게시 중단 + 알림. 뚫린 상태를 밖으로 못 나가게 한다.시연 앞두고 fit-check 24명 중 23명을 새 STAR 콘텐츠로 재접수. append-only 트리거가 막고 있어 임시 해제가 필요했다. 실행 스크립트는 사후 삭제.
document_anchors 의 append-only 트리거가 DELETE 를 거부. 지원자 삭제도 document_anchors.application_id FK 때문에 실패. 트리거 해제 없이는 불가능.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;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 로 남김.ENABLED · DELETE 시도 시 ERROR: 무결성 원장은 삭제할 수 없습니다근거: 오전 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 미구현 명시).