Skip to content

Latest commit

 

History

History
561 lines (481 loc) · 42.8 KB

CLAUDE.md

File metadata and controls

561 lines (481 loc) · 42.8 KB

AI Server — Claude 컨텍스트

StackUp AI 서버. Python 3.13 + FastAPI + LangChain. RabbitMQ consumer로 동작하며 LLM 호출, RAG, 음성 분석을 담당.

상위 컨텍스트: /CLAUDE.md · 횡단 관심사: /docs/


1. 기술 스택

영역 기술
Language Python 3.13 (.python-version 고정)
패키지 매니저 uv (uv.lock 락파일)
Framework FastAPI 0.135+
ASGI uvicorn (standard)
스키마 Pydantic 2.x + pydantic-settings
HTTP httpx
MQ aio-pika (async AMQP)
WebSocket FastAPI WS (서버) + websockets (Deepgram Live 클라이언트)
객체 스토리지 boto3 (S3 호환)
LLM LangChain 1.x (core + community)
로깅 structlog
빌드 hatchling
테스트 pytest + pytest-asyncio
포맷/린트 black, flake8, pylint

2. 디렉토리 구조

ai/
├── pyproject.toml
├── uv.lock
├── .python-version
├── Dockerfile
├── .env.example
├── src/
│   └── ai_server/
│       ├── __init__.py
│       ├── main.py              # FastAPI 앱 팩토리
│       ├── api/                 # FastAPI 라우터 (health 등)
│       ├── config/
│       │   └── settings.py      # pydantic-settings (env)
│       ├── analyzer/            # 이력서·레포·웹 분석 오케스트레이션
│       │   ├── resume_analyzer.py
│       │   └── sources/         # SourceExtractor 추상화 + 구현체 (PDF; repo/web 후속)
│       ├── chain/               # LangChain 체인 + 프롬프트
│       │   ├── document_analysis_chain.py
│       │   └── prompts/
│       ├── storage/             # ObjectStorage 추상화 (local fs / s3)
│       ├── messaging/           # RabbitMQ consumer/publisher
│       ├── model/               # Pydantic 모델 (envelope, 메시지 페이로드)
│       ├── rag/                 # (계획) 청킹·임베딩·pgvector
│       └── voice/               # (Phase 2) STT/TTS, 음성 분석
└── tests/

3. 책임 매트릭스

모듈 책임
main.py FastAPI 부트스트랩, lifespan에서 RabbitMQ consumer 시작
config/settings.py 환경변수 → 타입 안전 설정 객체
api/ 헬스체크 + (필요 시) 내부 디버그 API
messaging/ aio-pika consumer (큐별), publisher
analyzer/ 이력서/레포 분석 use case (PDF 추출, GitHub fetch, 마크다운 생성)
chain/ LangChain prompt template + chain composition
rag/ 청킹, 임베딩 생성, pgvector 검색 호출 (Core API 경유)
voice/ STT/TTS 어댑터, WPM/filler/silence 분석
storage/ S3 GET/PUT 래퍼
model/ RabbitMQ envelope, request/response Pydantic 모델

4. 비책임 (명시적)

  • ❌ PostgreSQL 직접 접근 — Core 서버 API 경유 또는 RabbitMQ 메시지에 데이터 동봉
  • ❌ JWT 발급·검증 — 인증은 Core
  • ❌ REST CRUD API 노출 — 외부 트리거는 RabbitMQ만
  • ❌ 사용자 인증 (내부 통신만) — api/는 헬스체크 / 내부 도구

5. 메시징 (RabbitMQ)

본 서버는 RabbitMQ consumer로 작동.

Queue Routing key 상태
ai.analyze.resume analyze.resume 본 구현 (PDF → MD)
ai.analyze.repository analyze.repository 본 구현 (GitHub README + tree + 소스 sampling)
ai.analyze.web analyze.web 본 구현 (URL → trafilatura, SSRF 가드 analyzer/sources/url_guard.py)
ai.analyze.cover_letter analyze.cover_letter 본 구현 (자소서 문항 inline 텍스트 → MD, TextSourceExtractor)
ai.generate.questions generate.questions 본 구현 (Pro 모델, 질문 풀 생성, US-18)
ai.generate.followup generate.followup 본 구현 (Flash 모델, 답변 평가+꼬리질문, US-19)
ai.generate.tts generate.tts 본 구현 (질문 음성화, OpenAI TTS → S3 → callback.tts)

콜백 발행: ai.callback.{type} 익스체인지. 상세 envelope/스키마/재시도: /docs/messaging.md.

consumer 패턴 (aio-pika)

async def consume_resume_analyze(message: AbstractIncomingMessage) -> None:
    async with message.process(requeue=False):  # auto ack on exit
        envelope = parse_envelope(message)
        with trace_context(envelope.trace_id):
            await resume_analyzer.handle(envelope.payload)

멱등 처리

  • envelope의 messageId를 PostgreSQL processed_messages 테이블 (Core API 경유) 또는 AI 프로세스 인메모리 LRU 캐시에 기록
  • 이미 존재하면 skip + ACK
  • (Redis 미사용 — DB 1쿼리 부담을 감수하거나, 인메모리만 쓰고 재시작 시 RabbitMQ delivery_tag로 보조)

6. LLM 사용 패턴

6.1 게이트웨이

모든 LLM 호출은 Mindlogic CNU AC Gateway (OpenAI 호환) 단일 엔드포인트로 통과한다.

  • LLM_BASE_URL (default https://factchat-cloud.mindlogic.ai/v1/gateway)
  • LLM_API_KEY (학교 발급, secret/.env로만 주입)
  • 모델 카탈로그: Claude / GPT / Gemini / xAI / Perplexity / Solar / Gemma 등을 단일 키로 사용.
  • 게이트웨이 교체(자체 OpenAI 키, vLLM 등) 시 LLM_BASE_URL 한 줄만 변경.

6.2 모델 선택

시점 모델 (env override 가능) 용도
세션 시작 Pro (gemini-3.1-pro-preview 기본) 질문 풀 (품질)
세션 중 Flash (gemini-3.5-flash-lite 기본) 꼬리질문 (저지연 < 3s)
분석 (이력서/레포) Pro 마크다운 구조화

설정은 settings.py + 환경변수로 모델명 주입 (코드에 하드코딩 금지).

주의 — Pro 기본값이 -preview 별칭이다. 게이트웨이가 preview 별칭을 은퇴시키면 질문 풀· 분석·피드백이 한꺼번에 죽는다(모두 Pro 를 쓴다). 그 경우 .env 의 LLM_PRO_MODEL 한 줄로 정식 별칭으로 넘어가면 되고 재배포·코드 수정이 필요 없다 — 이 표와 settings.py 기본값은 "지금 게이트웨이에서 검증된 이름"의 기록이지 유일한 진실이 아니다.

6.2-1 재시도 예산 — 타임아웃은 호출 상한이 아니었다 (2026-09-29)

llm_*_timeout_sec 를 "이 호출은 최대 N초" 로 읽으면 틀린다. OpenAI SDK 클라이언트의 max_retries 기본값이 2 라 LangChain 호출 1회가 HTTP 3회이고, 코드가 그 값을 지정한 적이 없었다. 그래서 실제 상한은 설정값의 (1+n)배다.

  • ai_request_logs 는 LangChain 호출 1건에 한 행이므로 latency_ms 는 숨은 시도의 합이다. 운영 analyze.document 실패 4건이 타임아웃 60초 설정에서 전부 92.1~92.6초로 찍힌 이유이고, §6.2 주석의 "타임아웃 30초 + 재시도 26초 = 56초" 관측도 같은 현상이다.
  • 이제 12개 ChatOpenAI 전부가 max_retries=settings.llm_max_retries 를 명시한다 (기본 2 — 현 동작 유지). 게이트웨이 429 를 흡수하는 값이라 0 으로 내리지 않았지만, 보이지 않는 채로 두지 않는다. 한 곳만 빠지면 그 경로만 조용히 3배로 돌아간다 — tests/test_llm_retry_budget.py 가 전 빌더를 훑는다.
  • 문서 분석만 max_retries=0 이다. 바깥에 with_retry 가 있어 두 층이 곱해지면 한 메시지가 HTTP 를 6번 치고 로그 한 행이 여러 시도의 합이 된다.

문서 분석 재시도 간격 (같은 커밋)

with_retry 의 기본 대기는 1초(+지터 1초)라 사실상 연속 시도다. 게이트웨이가 구간적으로 멎으면 두 시도가 같은 구간에 들어가 함께 죽는다 — 운영에서 4/4 가 그랬다.

  • 실측: resume_id=18 이 연속 2시도(각 92초)로 실패해 4일간 FAILED 로 남았고(문서 34), 사용자가 재분석을 눌러도 또 2시도 실패(문서 35), 3분 뒤 세 번째 재분석이 37초에 성공 (문서 36)했다. 입력 문제가 아니라 상류 간헐 정지다(Deepgram 배치 호출과 같은 성질 — §8).
  • document_analysis_max_attempts(3) + document_analysis_retry_initial_sec(15) 로 간격을 15초→30초로 벌린다. 소비자 prefetch 가 10 이라 한 문서가 오래 붙잡혀도 다른 분석을 막지 않는다.
  • retry_if_exception_type 기본값이 Exception 이라 이 재시도는 파싱 실패뿐 아니라 타임아웃도 잡는다 — 원래 의도(스키마 파싱 안전장치)와 달리 실제로 걸린 건 전부 타임아웃이었다.

6.3 LangChain 사용 (OpenAI 호환 클라이언트)

from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

prompt = ChatPromptTemplate.from_messages([
    ("system", "당신은 면접관입니다..."),
    ("human", "이력서: {resume}\n질문 후보: ..."),
])
llm = ChatOpenAI(
    model=settings.llm_pro_model,
    base_url=settings.llm_base_url,
    api_key=settings.llm_api_key,
)
chain = prompt | llm | PydanticOutputParser(pydantic_object=...)
  • 모든 프롬프트는 chain/prompts/{name}.py 단일 위치
  • 프롬프트 변경은 PR로 (커밋 메시지에 의도 명시)
  • 응답은 반드시 schema validation (Pydantic) — LLM 결과를 그대로 신뢰 X

6.4 호출 로깅

호출 시작/완료/실패를 Core 서버에 RabbitMQ로 보내 ai_request_logs에 기록 — 또는 자체 endpoint POST. 필드: request_type, model_name, input_tokens, output_tokens, latency_ms, status.

상세: /docs/observability.md §3.


7. RAG 파이프라인

7.1 인제스트 (본 구현)

  1. 마크다운 입력 (analyzer/_embedding_step.chunk_embed_and_upsert)
  2. 청킹 — rag/chunker.MarkdownChunker (RecursiveCharacterTextSplitter, 기본 1000/200)
  3. 임베딩 생성 — rag/embedder.EmbeddingProvider. 현재 구현체:
    • MockEmbeddingProvider (default) — 차원 결정 보류, e2e 흐름 검증용
    • openai / ollama 구현체는 후속 PR
  4. Core API 호출 — CoreClient.upsert_embeddings(document_id, model, dim, chunks) → PUT /api/internal/documents/{id}/embeddings (idempotent upsert)

7.2 검색

  1. 쿼리 텍스트 → 임베딩
  2. Core API: POST /api/internal/embeddings/search (top_k 검색)
  3. 검색 결과 + 추가 메타데이터 → 프롬프트에 주입

Core가 pgvector 단일 진입점을 제공하므로 AI는 직접 pg 호출 X.


8. 음성 처리 (Phase 2)

  • STT: OpenAI Whisper API 채택 (한국어 + 개발 영어 혼용 환경 정확도 우수)
    • 비용: $0.006 / 분 (1시간 면접 ≈ ₩500 / USD ≈ $0.36)
    • 셀프호스팅 옵션: whisper.cpp 또는 faster-whisper (GPU 권장, 비용 ↓ but 운영 부담 ↑)
    • 브라우저 내장 SpeechRecognition API는 정확도 부족으로 채택 안 함
  • 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 모델 nova-2 로 교체 (2026-09-27): 운영 음성 3종을 30회 돌려 재시도 없이 측정하니 whisper-large 는 23%(7/30)가 20초 read 타임아웃이었고 파일별 편차가 컸다(60% / 10% / 0% — 크기와 무관). 재시도 3회가 대부분 흡수하지만 최악 오디오는 5번에 1번꼴로 사용자에게 실패가 보이고, 실패마다 20초씩 더 기다린다(3회 전부 실패 시 61초). 같은 조건에서 nova-2 는 28/28 성공, 응답도 2~3배 빠르다. nova-3 는 가장 빠르지만 없는 말을 지어내고("아 네. 질문 지레인데?") imbeded·readis 처럼 영문 철자가 깨져 제외했다. 한국어 품질은 whisper ≈ nova-2 로, whisper 가 기술 영문을 원문 유지(Revalidation Event)하는 반면 nova-2 는 한글화(리밸리데이션 이벤트)하는 차이 정도다.
    • 부작용 2개를 같이 처리했다. ① keywords 힌트 경로가 켜진다(Whisper 는 400 이라 잠들어 있었다). ② pronunciation_accuracy 는 STT 신뢰도라 모델마다 분포가 다르다. Core 의 PRONUNCIATION_LOW 를 0.85→0.93 으로 재보정(실측: whisper 중앙 0.841 → 0.85 기준 57% 가 '불분명'으로 이미 무의미했다 / nova-2 중앙 0.976, 0.93 기준 15%).
  • keywords 힌트는 보내지 않는다 (2026-09-27, 위 ①의 후속): 모델 교체 직후엔 "200 응답 + 전사 동일"만 보고 안전하다고 판단했는데, 비교 1회로는 효과 없음과 비결정성을 구분할 수 없었다. 같은 오디오로 힌트 유/무를 5회씩 돌리니 두 조건의 출력 분포가 동일했다 (각각 297자 4회 + 285자 1회). 즉 차이처럼 보이던 것은 힌트가 아니라 nova-2 자체의 비결정성이다. 단어 하나 + 강도(백오프:10)로 줄여도, 영어 단어(jitter:10)로 바꿔도 같았다 — 한국어에서 Deepgram keyword boosting 은 동작하지 않는다.
    • 그래서 params["keywords"] 를 뺐다. 효과가 없는데 매 호출마다 질문 200자를 외부로 보내고, Deepgram 이 언젠가 이 값을 반영하기 시작하면 답변 전사가 질문 어휘 쪽으로 끌려간다 — 채점하는 서비스에서 그건 사용자가 하지 않은 말로 감점되는 것이다. hint 인자 자체는 SttProvider 공통 인터페이스라 남겨 둔다.
    • keyterm(nova-3 용 후속 파라미터)은 nova-2 에서 400 이다.
    • 덤으로 알게 된 것: nova-2 는 비결정적이다. 같은 파일 10회 중 2회가 마지막 문장 ("여기까지 하겠습니다.")을 빠뜨렸다. 재전사(retranscribe)가 다른 결과를 내는 것은 버그가 아니라 이 성질 때문이며, 실패가 아니어도 재전사가 도움이 될 수 있다는 뜻이다.
  • 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
  • 분석:
    • WPM = words / minutes
    • 간투어: 한국어 정규식 r"\b(음+|어+|그+)\b" 카운트
    • 침묵: VAD (Voice Activity Detection) 라이브러리 결과 합산

9. 환경 변수

config/settings.py의 Settings 클래스가 진실 공급원 (single source of truth). 필수 변수는 default 없음 → 부팅 실패로 누락 감지.

class Settings(BaseSettings):
    rabbitmq_url: str
    s3_endpoint_url: str
    s3_access_key: str
    s3_secret_key: str
    s3_bucket_name: str
    llm_api_key: str = ""
    gemini_api_key: str = ""
    llm_pro_model: str = "gemini-3.1-pro-preview"
    llm_flash_model: str = "gemini-3.5-flash-lite"
    embedding_model: str = "gemini-embedding-001"
    embedding_dim: int = 1536
    core_server_base_url: str = "http://core:8080"

전체 환경변수 카탈로그: /docs/environment.md §4.


10. 로깅 (structlog)

import structlog
log = structlog.get_logger()

log.info("resume.analyze.start", resume_id=42, trace_id=trace_id)
log.error("resume.analyze.failed", resume_id=42, error_code="PDF_PARSE_FAILED", exc_info=True)
  • JSON 출력 (운영) / human pretty (로컬)
  • 컨텍스트 변수로 trace_id, user_id 자동 주입
  • 민감정보 (이력서 본문, 답변 본문) 절대 X
  • 자세한 정책: /docs/observability.md

11. 테스트

uv run pytest                       # 전체
uv run pytest tests/test_rag.py     # 특정 파일
uv run pytest -k "embedding"        # 키워드
  • 비동기 테스트는 pytest-asyncio (@pytest.mark.asyncio)
  • LLM 호출은 mock (LangChain FakeListLLM)
  • RabbitMQ는 Testcontainer 또는 aio-pika mock
  • 자세한 전략: /docs/testing-strategy.md

12. 코드 스타일

  • black (line-length 88)
  • pyproject.toml 기준
  • 함수/변수: snake_case
  • 상수: UPPER_SNAKE_CASE
  • 클래스: PascalCase
  • 타입 힌트 필수 — PEP 604 union(int | None) 사용. 파일 첫 줄의 from __future__ import annotations 는 코드베이스 전반의 실제 컨벤션이므로 유지 (과거 "없이" 라는 서술은 코드와 불일치했던 stale 규정 — 2026-08-23 정정)
  • async first — sync IO 사용 시 명시적 이유

상세 공통 규약: /docs/coding-conventions.md.


13. 빌드·실행

uv sync                                              # 의존성 설치
uv run uvicorn ai_server.main:app --reload          # 개발 실행
uv run python -m ai_server.messaging.runner         # consumer 단독 실행 (도입 후)

# Docker
docker build -t stackup-ai .
docker run --env-file .env -p 8000:8000 stackup-ai

docker-compose.yml에 ai 서비스 추가는 추후 (현재는 Core/PG/MQ/MinIO만 있음).


14. 새 기능 추가 절차

  1. 메시지 스키마 정의 → model/messages.py
  2. RabbitMQ routing key 결정 → /docs/messaging.md 갱신
  3. consumer 작성 → messaging/{name}_consumer.py
  4. 비즈니스 로직 → analyzer/ / chain/ / voice/ 적절한 모듈
  5. 프롬프트 (LLM 호출 시) → chain/prompts/{name}.py
  6. 단위 테스트 (LLM mock) + 통합 테스트 (Testcontainer)
  7. 본 문서 §3, §5 갱신

15. 안티패턴

  • ❌ 프롬프트를 코드 안 곳곳에 산재시키기 → chain/prompts/로 모은다
  • ❌ LLM 응답을 파싱 없이 그대로 사용 → 항상 Pydantic schema validation
  • ❌ 동기 라이브러리(requests, pika) 사용 → async 라이브러리(httpx, aio-pika)
  • ❌ 트랜잭션이 필요한 작업 (PG 직접 접근) → Core API 호출
  • ❌ messageId 멱등 체크 누락 → 중복 처리 위험
  • ❌ 프롬프트에 사용자 답변을 그대로 system message로 → injection 가능. 반드시 user message로.

16. 현재 상태 (2026-05 기준)

  • FastAPI 부트스트랩 + 헬스체크

  • 분석 consumer 본 구현 — analyze.resume / analyze.repository / analyze.web:

    • PDF·GitHub Repo·웹 URL 소스 추출 추상화 (analyzer/sources/)
    • 웹 URL 은 SSRF 가드 필수 (analyzer/sources/url_guard.py): 이 프로세스는 docker 네트워크에서 Core·PG·RabbitMQ·MinIO 에 닿고 배포 호스트에서는 클라우드 메타데이터(169.254.169.254)에도 닿는다. 스킴·userinfo 검사 + 호스트를 해석한 주소로 사설/루프백/링크로컬/멀티캐스트/예약/IPv6 unique-local 차단. follow_redirects 는 끄고 홉마다 재검증(상대 Location 은 절대화 후 검사, 5홉 제한). Playwright 렌더 폴백도 검증된 최종 URL 로 실행. Core 의 WebResumeUrlValidator 는 첫 관문이고 DNS rebinding·리다이렉트로 우회되므로 소켓을 여는 이쪽 검사가 실질 방어선이다.
    • LLM 분석 (chain/document_analysis_chain.py, Gemini Pro + Pydantic 출력 파서)
    • 분석 MD를 스토리지에 저장
    • callback.analysis 발행 (status ANALYZED / FAILED, retriable 플래그 포함)
  • 면접 consumer 본 구현 — generate.questions (US-18) / generate.followup (US-19):

    • 질문 풀 생성 (Pro 모델, chain/question_generation_chain.py)
    • 꼬리질문 + 답변 평가 (Flash 모델, chain/followup_generation_chain.py)
    • 콜백: callback.questions (kind=POOL|FOLLOWUP)
    • 자기소개 기반 질문 생성: generate.questions 는 자기소개 답변을 받은 뒤 발행되며, payload 의 selfIntroAnswer 를 프롬프트(chain/prompts/question_generation.py)의 1차 근거로 사용한다(없으면 자료만으로).
    • 약점 집중 질문 생성: payload 의 focusAreas(TECHNICAL|LOGIC|COMMUNICATION, 지난 면접에서 낮았던 축)가 채워지면 프롬프트의 집중 영역 블록(_format_focus_areas, 축별 질문 설계 관점을 _FOCUS_GUIDE 로 명시)이 그 영역을 검증하는 질문을 과반으로 배치하게 한다. 라벨만 넘기면 LLM 이 제각각 해석하므로 관점 문구를 붙인다. 근거 없는 질문 생성 금지 조건은 그대로 — 자료가 부족하면 집중 비중을 줄인다. 지정이 없으면 "(지정 없음)" 이 들어가 일반 면접과 동일.
    • 직무 맞춤(JOB_TAILORED) 질문 생성: mode=JOB_TAILORED 면 payload 의 targetCompanyName· targetJobDescription(JD)이 채워진다. 프롬프트가 target_role 블록으로 받아 이력서↔JD 교집합·갭, JD 요구 충족 검증, 지원동기·컬처핏 질문을 우선 생성한다(다른 모드는 '(일반 면접)' 안내라 무시).
  • 자기소개 첫인상 평가 본 구현: FeedbackConsumer 가 messages[] 에서 category=SELF_INTRODUCTION 질문+답변을 찾아 LlmSelfIntroEvaluator(Flash, chain/prompts/self_intro_evaluation.py)로 첫인상 (전달력·구조·간결성·직무적합성)을 평가하고, 결과를 panelBreakdown 의 evaluator="첫인상" 항목으로 덧붙인다(종합 generate 와 asyncio.gather 병렬, 실패해도 피드백 계속). 이 항목은 종합 점수 집계에 미포함 — 메인 generator 가 모른 채 overall 을 계산한 뒤 표시용으로만 append 한다. 레거시 세션(자기소개 없음)·빈 답변은 건너뛴다.

  • 질문별 복기(답변 코칭) 본 구현: FeedbackConsumer 가 자기소개 제외 모든 (질문,답변) 쌍을 찾아 LlmAnswerCoach(Flash, chain/prompts/answer_coaching.py)로 답변별 병렬 코칭 — 모범 답안 + 내 답변 리라이트 + 한 줄 코칭. callback.feedback.answerCoaching[{messageId,…}] 로 보내고 Core 가 각 답변 메시지에 기록(종료 세션 조회에서만 노출). 종합 generate·첫인상·직무 적합도와 asyncio.gather 병렬.

  • 마크다운 학습 리포트 저장 본 구현 (reportS3Key): FeedbackConsumer._save_report 가 성공 payload 조립 직전에 chain/feedback_report.py::render_feedback_report(LLM 미호출 결정론 렌더 — 점수 표·패널· 요약·키워드·학습 플랜·하이라이트·답변별 코칭·음성 요약을 GFM 문서로 조립)를 호출해 feedback/{session_id}/report.md(FEEDBACK_REPORT_MD_KEY_TEMPLATE, storage.md §2)에 put_text 저장, 키를 callback.feedback.reportS3Key 로 동봉한다. 실패는 전부 삼키고 None 폴백 — _process 의 예외는 공용 가드가 FAILED 콜백으로 승격시키므로, 부가 산출물인 리포트가 피드백 전체를 죽이면 안 된다 (feedback.report.save_failed warning). 사용자 답변 원문은 리포트에 싣지 않는다(사용자 입력을 마크다운으로 렌더하지 않는 규칙 — 질문·코칭·GFM 계약 필드만). 소비는 Core 프록시 GET /api/sessions/{id}/feedback/report.

  • 직무 적합도 + 직무 이해도 평가 본 구현: mode=JOB_TAILORED + JD 있을 때 LlmJobFitEvaluator(Pro, chain/prompts/job_fit_evaluation.py)가 면접 전사·자료를 채용공고(JD)와 대조해 두 축을 한 번의 구조화 호출(JobFitResult{fit, understanding})로 평가: 직무 적합도(JD 요구 역량 매칭) + 직무 이해도 (직무가 무엇을 하는 자리인지 이해·지원동기). 두 축을 panelBreakdown 의 evaluator="직무 적합도"· evaluator="직무 이해도" 항목으로 append(첫인상과 같은 병렬·미집계 메커니즘). 그 외 모드/빈 JD/실패는 건너뜀.

  • 꼬리질문 토큰 스트리밍 본 구현: followup 출력을 <intent>…</intent><question>…</question><meta>{json}</meta> 구분자 포맷으로 바꾸고(chain/prompts/followup_generation.py), StreamingFollowupGenerator(astream)가 <question> 토큰만 SessionRealtimeNotifier(messaging/session_notify.py)로 SESSION_MESSAGE_DELTA 발행(stackup.realtime/realtime.session.notify, Core 우회). DONT_KNOW 면 델타 미발행. 종료 후 parse_followup_result 로 검증해 기존 callback.questions(FOLLOWUP, followupMessageId) 발행. 와이어링은 messaging/runner.py(분석 진행 publisher 재사용).

  • 평가위원 앵커 축별 분리 (2026-09-29): 패널 평가위원에게 자기 축 기준값만 준다 (build_axis_score_basis → PanelFeedbackGenerator(axis_score_basis=…)). 전에는 네 축(technical/logic/communication/overall)이 담긴 같은 블록을 모든 위원에게 똑같이 넘겼다. 문제가 셋이었다:

    • 프롬프트는 "해당 축 기준값이 있으면 ±15점 이내"라고 하는데, 기준값 라벨 (logic_score)과 위원의 축 이름(논리·인과관계 명확성)이 달라서 위원이 스스로 짝을 찾아야 했다. 직군 위원은 축 이름이 "직무 역량·깊이"인데 라벨은 technical_accuracy 라 더 멀다.
    • overall_score 가 보였다 — 어느 위원도 써서는 안 될 앵커다.
    • 남의 축 숫자는 앵커링으로 새어 들어간다(논리 위원이 technical 80 을 보면 자기 점수를 그쪽으로 당긴다).
    • 직군 위원은 여러 명이라 key 가 tech:{직군} 이다 — axis_of 가 이걸 technical 로 접는다. 하나라도 빠지면 그 위원만 조용히 옛 동작(전체 기준값)으로 떨어진다.
    • 축별 기준값이 없으면(per-answer 평가 0건) 기존 공용 문자열로 폴백한다. 앵커가 통째로 사라지면 점수가 캘리브레이션 없이 흔들린다.
    • 단일 생성기(LlmFeedbackGenerator)는 네 축을 한 번에 내므로 전체 기준값이 필요하다 — 그래서 선택 인자이고, 소비자가 inspect.signature 로 지원 여부를 보고 넘긴다.
    • 숫자 계산은 _score_baselines 한 곳으로 모았다(전체 문자열과 축별이 달라지면 안 된다).
  • 정직한 "모르겠습니다" 채점 정정 (2026-09-29): 답변 평가 점수를 전부 nullable 로 바꾸고 "null = 채점 대상 아님"을 집계 전 구간에 관철했다. _mean 이 None 을 제외하므로 0 과 null 의 차이가 곧 점수 차이인데, 세 곳이 어긋나 있었다.

    • AnswerEvaluation.specificity/logic 이 required 였다. 프롬프트는 DONT_KNOW· 확인형 단답에 null 을 지시하고 있었는데, LLM 이 그대로 따르면 model_validate_json 이 터지고 parse_followup_result 가 그 예외를 삼켜 평가가 통째로 사라졌다(structure 까지). 진짜 파싱 실패와 구분도 안 됐다. → float | None 로. 받는 쪽(MessageEvaluation)은 처음부터 전부 nullable 이었다.
    • 프롬프트가 "보수적으로(낮게/null)" 로 모호했다. 모호하면 LLM 이 매번 다르게 고른다. → "반드시 null" + meta 스키마에도 <0~5 또는 null> 명시.
    • _STRUCTURE_SCORE["NONE"] = 0.0 이라 채점 불가 답변의 structure 가 0 으로 집계됐다. 전부 모름인 세션이 communication ≈ 0, overall ≈ 0 으로 나왔다(테스트가 잡았다). → _is_scoreable(specificity·logic 이 둘 다 null 이면 채점 불가)로 structure 도 제외. structure="NONE" 자체는 정상 감점 사유다 — 길게 답했는데 구조가 없는 경우. 구분 기준은 structure 값이 아니라 채점 가능 여부다.
    • 기준값 문자열에 "채점 대상 아님 N건(… 감점 사유가 아님)" 을 넣는다. 숫자만 주면 LLM 이 "답변 수가 적네" 로 읽고 깎을 수 있다.
  • 짧은 확인 질문/답변 커버 본 구현: followup 프롬프트(chain/prompts/followup_generation.py)가 매 턴 깊이 파기 대신 "그럼 OO 하신 건가요?" 같은 짧은 확인형 질문도 던지도록 안내하고, 직전 답변이 확인형 단답('네 맞습니다'·'아뇨 그건 아닙니다')이면 specificity/logic/correctness=null·structure=NONE 으로 두어 짧다는 이유로 감점하지 않도록 지시. 피드백 단계에선 feedback_consumer._is_short_confirmation(확인/부정 표현으로 시작 + 짧은 길이) 으로 그런 단답을 질문별 복기(코칭) 대상에서 제외 — '네 맞습니다'에 모범답안/리라이트가 붙지 않게 한다(결정론적). 출력 포맷·콜백 스키마 무변경.

  • 문장 단위 TTS 본 구현 (Part B): followup consumer 스트림 루프가 chain/sentence_split.next_sentences 로 문장 경계를 잡아, 문장마다 TtsProvider 인라인 합성(asyncio.create_task 백그라운드, 텍스트 델타 비차단)→S3 interview/tts/{sid}/{mid}/seg-{seq}.{ext} PUT→SessionRealtimeNotifier.emit_audio(SESSION_MESSAGE_AUDIO). 콜백 전 gather 로 수거. 라이브 세그먼트는 휘발성(DB 미기록).

  • 임베딩 본 구현 (rag/): MarkdownChunker + GeminiEmbeddingProvider (1536d, gemini-embedding-001). 운영/개발 default 는 gemini, 테스트는 MockEmbeddingProvider.

    • 청크를 EMBEDDING_BATCH_SIZE(기본 32) 단위로 쪼개 순차 호출 — 한 요청에 몰면 분당 토큰 한도(429 RESOURCE_EXHAUSTED)에 걸린다.
    • 429 는 지수 백오프(EMBEDDING_MAX_RETRIES/EMBEDDING_RETRY_BASE_DELAY_SEC, 상한 30s)로 재시도. 소진 시 GEMINI_RATE_LIMITED(retriable), 그 외 오류는 GEMINI_FAILED(retriable)로 즉시 실패.
  • 스토리지 추상화 (storage/): S3Storage(기본) / LocalFilesystemStorage. STORAGE_BACKEND 토글.

  • LLM 호출 로깅 본 구현 (observability/llm_logging_callback.py, US-30): LangChain AsyncCallbackHandler 가 토큰/latency 측정 → Core /api/internal/ai-logs POST.

  • 체인 밖 외부 호출 로깅 본 구현 (observability/ai_call_log.py): 위 콜백은 LangChain 경로만 잡아서 TTS·임베딩·라이브 STT 가 ai_request_logs 에 한 줄도 남지 않았다 — 운영 TTS 요청 245건 중 43건이 실패했는데 원인을 사후에 알 수 없었고, "지금 정상"이 아니라 "안 보임"이었다. 공용 record_ai_call/measure_ai_call 로 세 경로를 계측한다:

    • TTS — voice/tts/logging_provider.py: LoggingTtsProvider 데코레이터(request_type=tts.synthesize). consumer 가 아니라 provider 를 감싼 이유는 TTS 가 generate.tts consumer 말고도 followup consumer 의 문장 단위 인라인 합성(Part B)에서 호출되기 때문 — consumer 쪽만 계측하면 라이브 경로가 통째로 빠진다. mock 은 외부 호출이 아니라 감싸지 않는다.
    • 임베딩 — rag/embedder.py 가 시도마다 한 행(request_type=embedding.embed, error_message 앞에 [n/N]). 429 재시도로 사용자에겐 실패가 안 보였지만 몇 번 만에 통과하는지 아무도 몰랐다 — batch_size·백오프를 근거 있게 조정하려면 이 숫자가 필요하다.
    • 라이브 STT — api/voice_stream.py 가 스트림 1건당 한 행(request_type=stt.live). 빈 전사는 FAILED 로 남겨 무음과 상류 무응답을 구분할 단서를 만든다. MessagingRuntime.core_client 를 노출해 WS 핸들러(app.state.core_client)도 같은 클라이언트를 쓴다.
  • 질문 TTS consumer 본 구현 (messaging/consumers/tts_consumer.py, voice/tts/): generate.tts 수신 → TTS 합성(GatewayTtsProvider/GeminiTtsProvider, mock fallback) → S3 PUT(interview/tts/{sessionId}/{messageId}.*, 확장자는 content_type 기준) → callback.tts 발행.

  • 실시간 스트리밍 음성 답변 본 구현 (RT3, api/voice_stream.py, voice/stt/{live,mock_live,deepgram_live,live_factory}.py): FastAPI WS /internal/voice/stream 수신(RealTime 프록시 경유) → Deepgram Live(deepgram_live.py, mock fallback)로 부분/최종 자막 다운 → 발화 종료 시 메트릭 계산 후 callback.voice 발행. VoiceCallbackService/followup 무변경 재사용. 신규 의존성 websockets.

  • 배치 음성 분석(STT/WPM/filler) 모듈은 voice/stt/whisper_api.py(+ Deepgram) + voice/analysis/metrics.py로 본 구현(analyze.voice consumer)

  • 비개발 직군 면접 본 구현 (2026-09-29): 서비스 대상이 취준생 전반(개발 8 + 비개발 12 직군)인데 평가 패널만 직군별로 확장돼 있었고 질문·꼬리질문·첫인상·문서분석 프롬프트는 "IT 직군" 페르소나로 고정돼 있었다. 그 상태의 영업·인사 지원자 면접은: IT 렌즈로 이력서가 요약되고 → IT 채용 담당자가 → 'CS 기초'·'기술 선택' 카테고리로 질문을 만들고 → IT 면접관이 꼬리질문을 단다. 평가만 직군 맞춤.

    • 카테고리가 개발 전용인 것이 핵심이다. 문구만 바꾸면 LLM 은 여전히 CS_FUNDAMENTAL 을 고른다. DOMAIN_KNOWLEDGE(직무 지식·업무 도구·업계 이해)를 추가하고, 비개발이면 CS_FUNDAMENTAL· TECH_CHOICE 를 쓰지 말라고 명시한다. Core 는 category 를 문자열로만 저장하므로(V9, CHECK 없음) 마이그레이션이 없고 프론트 categoryLabel 에 라벨만 더하면 된다.
    • job_category.py 에 ENGINEERING_CATEGORIES(8개) + is_engineering() 을 직군 목록과 같은 파일에 둔다 — 목록만 늘리고 집합을 빠뜨리면 그 직군이 조용히 반대 취급을 받는다(검증에서 안 튕긴다).
    • _format_job_guide 가 개발/비개발/혼합 3분기 블록을 만들어 {job_guide} 로 주입한다. 패널의 _DOMAIN_TECH_GUIDE 와 같은 이유로 라벨이 아니라 무엇을 묻고 무엇을 묻지 말지를 문장으로 준다. 직군이 비면 개발을 가정하지 않는다 — 틀렸을 때 비개발 지원자에게 기술 질문이 나가는 쪽이 더 나쁘다.
    • followup·self_intro 프롬프트는 예전부터 {job_category} 를 받고 있었는데 쓰지 않았다 (페르소나만 IT 고정). 받은 값을 페르소나에 넣었다.
    • document_analysis 는 업로드 시점이라 직군을 모른다 → 페르소나를 중립화하고 tech_stack 의 의미를 "개발이면 기술 스택, 아니면 그 직무의 실무 역량·도구·자격"으로 넓혔다(필드명 때문에 없는 기술을 지어내지 말라고 명시). 이게 가장 상류다 — 분석 마크다운이 RAG·질문·피드백 전부의 근거다.
    • 운영 사용 현황은 아직 BACKEND 64 / FRONTEND 39 로 비개발 세션이 0건이다 — 라이브 회귀가 아니라 미검증 경로를 연 것이다.
  • 멀티 평가위원 패널 본 구현 (chain/feedback_generation_chain.py: PanelFeedbackGenerator): 세션에 등장한 직군(질문 카테고리) 수만큼 기술 평가위원을 동적 생성(_domain_specs_weighted) + 논리·전달력 평가위원을 더해 asyncio.gather 로 병렬 채점 → 직군 평가는 질문 등장 빈도로 가중평균해 technical_accuracy 산출, 이를 논리·전달력과 고정 비율(0.5:0.25:0.25)로 다시 가중평균해 overall_score 결정(순수 파이썬, LLM 미호출). 직군별 평가 관점(dimension_guide)은 _DOMAIN_TECH_GUIDE(직군 20종 각각 실무 관점이 다른 문구 — 2026-09-28 에 개발 8종 + 비개발 12종으로 확장)로 실제로 차별화되어 있다 — 이전에는 persona 라벨만 다르고 가이드 내용이 공통이었던 문제를 회귀 테스트(tests/test_feedback_panel.py)로 막아둠. 목록에 없는 직군은 _TECH_GUIDE(범용)로 폴백 — 이 기본 문구는 기술 이야기를 하지 않는다. 서비스 대상이 취준생 전반이라 '기술 정확성·trade-off' 로 뭉뚱그리면 영업·인사 지원자에게는 평가 근거가 통째로 헛돈다(test_generic_guide_is_not_engineering_flavoured). 첫인상·직무적합도·인성 평가는 별도 체인으로 같은 병렬 흐름에 얹히지만 이 가중평균 계산에는 미포함(표시용). 평가위원 호출이 실패(레이트리밋·타임아웃 등)하면 score=None으로 가중평균에서 자동 제외되고, panel_breakdown의 detail에 실패 사실을 명시(_EVAL_FAILED_DETAIL)해 "판단 불가(정상)"와 "호출 실패"를 구분한다. feedback_consumer.py의 _generate_panel은 패널 generate() 자체가 예상 못 한 예외로 죽어도(다른 4개 부가 평가는 각자 예외를 삼키는데 이것만 그러지 않으면 top-level asyncio.gather 가 통째로 취소된다) 빈 FeedbackResult로 대체해 피드백 발행 자체는 항상 이어지게 한다.

  • 생성 실패 신호 공용 가드 본 구현 (F4 일원화): 질문 풀·꼬리질문·피드백 3개 consumer 의 실패 신호 메커니즘을 messaging/consumers/failure_signal.py: consume_with_failure_signal 하나로 통합했다. 이전엔 questions/followup 이 생성 호출만 try 로 감싸 컨텍스트 빌드·진행 이벤트·발행 구간이 무방비였고(같은 무기한 대기 버그를 세 번, 세 깊이로 수리), feedback 만 전 구간 가드였다. 이제 3개 모두: envelope 파싱 실패는 raise→DLQ, 파싱·멱등 이후 전 구간 예외는 항상 status=FAILED 콜백(+ack), 성공 콜백 발행 실패는 FAILED 오인 없이 원 예외로 DLQ(재처리 가능), 콜백 0건 DLQ 경로는 LruIdempotencyStore.unmark. 각 consumer 는 _process(envelope)(성공 payload 반환)와 _failed_payload(req, exc) 팩토리만 구현한다. errorCode 분류는 consumer 별 계약 유지 — questions/followup: GENERATION_SCHEMA_INVALID(TypeError, retriable=false) | GENERATION_FAILED, feedback: 동일 스키마 코드 | UNEXPECTED. errorMessage 는 공용 format_error_message(ExcType: msg, 500자 상한). F5/F6 확장: 분석 4개 (resume/web/repository/cover_letter)도 같은 가드로 전환(도메인 에러의 코드·retriable 분류는 각 _failed_payload 팩토리가 보존, action="analyze" 로 기존 로그 키 유지), voice/tts 는 직접-발행 모델을 유지하되 마킹 이후 전 구간을 unmark_on_error 로 감쌌다(재주입은 합성 재과금·세그먼트 재전송을 동반 — 수동 복구 전용). feedback 은 요청의 attemptId 를 성공/FAILED 콜백에 에코 — Core 가 대체된 이전 시도의 지연 FAILED 를 드롭하는 근거(messaging.md §5.10/§5.11). Core 쪽 처리는 backend/CLAUDE.md 참고. (질문 풀 RAG questions_rag_timeout_sec 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 (messaging/session_notify.py)가 realtime.session.notify 로 QUESTION_POOL_PROGRESS/FEEDBACK_PROGRESS 를 직접 발행(휘발성, 실패는 경고만). questions_consumer 는 CONTEXT_BUILDING→GENERATING→FINALIZING 순차 3단계, feedback_consumer 는 최상위가 asyncio.gather 병렬이라 순차 phase 대신 태스크 완료 카운터 (SCORING completed/total, 각 세부 평가 완료 시 emit)로 표현한다. 기존 AnalysisProgressNotifier (user 채널)는 무변경 — user 채널 경로 불변은 회귀 테스트(tests/test_progress.py)로 고정. 와이어링은 runner.py 에서 followup 과 동일 notifier 인스턴스 재사용. 스펙: docs/messaging.md §5.12-3.

각 도입 시 본 문서 갱신.