본문으로 건너뛰기

분석

목적

analysis-svc는 구조화된 통계로부터 시험 코멘터리와 학생별 피드백을 생성하는 워커입니다. grading과 report와 달리 큐 기반이 아닙니다. 호출자가 HTTP 요청을 보내면 생성된 텍스트 응답을 즉시 받습니다.

리포지토리 전반의 워커 모델은 워커 개요를 참조하세요.

소스 경로

경로역할
lumie-worker/services/analysis/main.pyFastAPI 앱, lifespan, 라우트 핸들러, 메트릭 마운트
lumie-worker/services/analysis/src/schema.py외부 입출력 요청 및 응답 모델
lumie-worker/services/analysis/src/usecase.pyLLM 호출 오케스트레이션과 metric
lumie-worker/services/analysis/src/domain/prompts.py한국어 프롬프트 템플릿과 프롬프트 빌더
lumie-worker/services/analysis/src/adapters/llm.pyAsyncOpenAI 클라이언트 어댑터
lumie-worker/services/analysis/src/joossameng/router.pyJoossameng 전용 AI 리포트 HTTP 라우트
lumie-worker/services/analysis/src/joossameng/service.pyAI 리포트 배치 오케스트레이션과 ZIP 조립
lumie-worker/services/analysis/src/joossameng/renderer.pyJoossameng AI 리포트 PDF용 ReportLab 렌더러
lumie-worker/services/analysis/assets/fonts/AI 리포트 PDF가 사용하는 번들 Noto Sans KR 폰트
lumie-worker/services/analysis/src/config.pyLLM_*와 OTel 설정
lumie-worker/services/analysis/tests/test_analysis_usecase.py유스케이스 동작 테스트
lumie-worker/services/analysis/tests/test_joossameng_renderer.pyAI report 폰트, 줄바꿈, pagination 테스트
lumie-worker/services/analysis/tests/test_joossameng_service.pyAI report job 오케스트레이션 테스트
lumie-worker/services/analysis/tests/test_observability.py메트릭 및 tracing smoke 테스트

공개 표면

라우트:

  • GET /
  • GET /health
  • GET /metrics
  • POST /api/analysis/exam-commentary
  • POST /api/analysis/student-feedback
  • POST /api/joossameng/ai-reports/exams/{exam_id}/batch
  • GET /api/joossameng/ai-reports/jobs/{job_id}
  • GET /api/joossameng/ai-reports/jobs/{job_id}/download

두 생성 라우트는 lumie-worker/services/analysis/src/schema.pyGenerationResponselumie-worker/services/analysis/main.py에서 응답 모델로 사용합니다.

{
"content": "..."
}

joossameng AI 리포트 라우트는 tenant가 제한된 확장 경로입니다. 이 라우트는 X-Tenant-Slug: joossameng이 필요합니다. 다른 tenant는 작업 생성 또는 조회 전에 worker 라우트에서 403을 받습니다.

요청 모델

/api/analysis/exam-commentary는 다음과 같은 집계 수준 시험 데이터를 받습니다.

  • 시험 이름
  • 응시자 수
  • 평균, 최고, 최저 점수
  • 등급 분포
  • 문제별 정답률 통계

/api/analysis/student-feedback는 다음과 같은 학생 수준 데이터를 받습니다.

  • 학생 이름과 시험 이름
  • 총점, 등급, 시험 평균
  • 선택 답안과 정답이 포함된 오답 문제
  • 문제 유형별 성취도

요청 및 응답 계약은 services/analysis/src/schema.py에 있으며, wire 형식에 대해 직접 Pydantic 검증을 사용합니다.

이 스키마는 백엔드와 프론트엔드 호출자가 이미 camelCase JSON을 사용하므로 alias 없이 의도적으로 camelCase 필드를 사용합니다. 모든 호출자를 같은 변경에서 함께 마이그레이션하지 않는 한 이 모델을 snake_case로 바꾸지 마세요.

Joossameng AI 리포트 배치 라우트는 lumie-worker/services/analysis/src/joossameng/router.pyAiReportBatchRequest에 정의된 studentIds 목록만 받습니다.

{
"studentIds": [101, 102]
}

이 라우트는 워커 내부 작업 id를 반환합니다. 상태 라우트는 처리, 성공, 실패 건수를 보고하며, 다운로드 라우트는 작업이 COMPLETED에 도달한 뒤 ZIP을 반환합니다.

시험 코멘터리 요청

이 예시는 lumie-worker/services/analysis/src/schema.pyExamCommentaryRequest를 기준으로 합니다.

{
"examName": "June Mock Exam",
"totalParticipants": 120,
"averageScore": 71.4,
"highestScore": 98,
"lowestScore": 22,
"gradeDistribution": [
{ "grade": 1, "count": 7, "percentage": 5.8 }
],
"questionStatistics": [
{
"questionNumber": 31,
"correctRate": 0.42,
"incorrectRate": 0.58,
"questionType": "빈칸추론",
"actualScore": 3
}
]
}

학생 피드백 요청

이 예시는 lumie-worker/services/analysis/src/schema.pyStudentFeedbackRequest를 기준으로 합니다.

{
"examName": "June Mock Exam",
"studentName": "Student A",
"totalScore": 84,
"grade": 2,
"averageScore": 71.4,
"incorrectQuestions": [
{
"questionNumber": 31,
"questionType": "빈칸추론",
"selectedChoice": "2",
"correctAnswer": "4",
"questionCorrectRate": 42.0
}
],
"questionTypeAchievement": [
{ "type": "빈칸추론", "correctCount": 2, "totalCount": 4, "correctRate": 50.0 }
]
}

생성 플로우

  1. FastAPI가 들어온 JSON을 타입이 지정된 요청 모델로 검증합니다.
  2. AnalysisUseCasesrc/domain/prompts.py의 순수 함수를 사용해 구조화된 입력에서 프롬프트를 만듭니다.
  3. 서비스가 openai.AsyncOpenAI를 통해 OpenAI 호환 chat completion API를 호출합니다.
  4. 반환된 메시지 내용을 GenerationResponse로 감싸서 반환합니다.

두 생성 경로는 주로 프롬프트 구성 방식에서 차이가 납니다.

  • 시험 코멘터리는 분포 수준 경향과 어려운 문제 패턴에 집중합니다
  • 학생 피드백은 한 학생의 실수, 문제 유형별 수행, 학습 가이드에 집중합니다

현재 프롬프트 템플릿은 교사 스타일의 일반 텍스트 출력과 한국어 응답에 맞춰져 있지만, 서비스 계약 자체는 텍스트 입력과 텍스트 출력으로 단순합니다.

Joossameng AI 리포트 플로우

Joossameng AI 리포트 경로는 같은 AnalysisUseCase를 재사용하지만, 원시 생성 텍스트 대신 렌더링된 PDF를 반환합니다.

  1. 백엔드가 X-Tenant-Slug: joossameng과 함께 worker의 POST /api/joossameng/ai-reports/exams/{exam_id}/batch 라우트를 호출합니다.
  2. worker가 인메모리 작업을 만들고 JoossamengAiReportService.run_job(...)을 FastAPI background task로 예약합니다.
  3. worker가 내부 HMAC 서명과 X-Tenant-Slug: joossameng을 사용해 백엔드 /internal/reports/exams/{exam_id}/batch-data에서 리포트 배치 데이터를 가져옵니다.
  4. 시험 수준 코멘터리와 학생 피드백은 있으면 Joossameng AI 캐시에서 읽고, 없으면 AnalysisUseCase로 생성한 뒤 캐시에 저장합니다.
  5. src/joossameng/renderer.py가 성공한 학생 항목마다 ReportLab으로 PDF를 렌더링합니다. 렌더링은 asyncio.to_thread(...)를 통해 실행되므로 CPU-bound PDF 작업이 이벤트 루프를 막지 않습니다.
  6. 성공한 PDF는 인메모리 ZIP에 기록합니다. ZIP writestr(...) 호출도 asyncio.to_thread(...)를 통해 실행되므로 DEFLATE 압축이 이벤트 루프에서 돌지 않습니다. 학생별 실패는 fail_count만 증가시키며 전체 작업을 중단하지 않습니다.
  7. 다운로드 라우트는 작업이 COMPLETED가 된 뒤에만 ZIP을 반환합니다.

PDF 렌더러는 먼저 services/analysis/assets/fonts/의 번들 NotoSansKR-Regular.ttfNotoSansKR-Bold.ttf를 시도합니다. TrueType 또는 OpenType 폰트를 등록할 수 없으면 플랫폼 폰트로 fallback하고, 마지막에는 ReportLab의 HYGothic-Medium CID 폰트를 사용합니다. 한글 본문은 ReportLab의 실제 문자열 폭을 기준으로 줄바꿈하고, 구조화된 LLM 출력의 보이는 들여쓰기를 보존하며, 긴 본문 박스를 페이지와 박스 경계 밖으로 넘기지 않고 여러 페이지로 나눕니다.

프롬프트 계약

프롬프트 빌더는 순수 함수입니다. 시험 난이도를 분류하고, 등급 분포를 요약하고, 오답률이 높은 문제를 고르고, 문제 유형을 묶은 뒤, 한국어 강사 스타일 프롬프트를 렌더링합니다. 유스케이스는 요청을 변경하거나 추가 데이터를 가져오지 않습니다.

생성 결과는 다음을 만족해야 합니다.

  • Markdown이 아닌 일반 텍스트
  • 한국어 교사 스타일 문장
  • 시험 코멘터리는 max_tokens=1024 제한
  • 학생 피드백은 max_tokens=2048 제한

제품 요구사항이 다국어 출력으로 바뀐다면 프롬프트 계약도 명시적으로 변경되어야 합니다. 현재 서비스는 요청 필드에서 출력 언어를 추론하지 않습니다.

설정 및 의존성

주요 설정:

  • LLM_API_KEY
  • LLM_BASE_URL
  • LLM_MODEL
  • LUMIE_BACKEND_URL
  • LUMIE_INTERNAL_HMAC_SECRET
  • JOOSSAMENG_DATABASE_DSN
  • OTEL_ENABLED
  • OTEL_ENDPOINT
  • OTEL_SERVICE_NAME

코드상의 기본 모델 설정은 다음과 같습니다.

  • base URL: https://api.openai.com/v1
  • model: gpt-4o-mini

이 서비스는 수명주기 hook에서 AsyncOpenAI 클라이언트 수명주기를 관리하고, 종료 시 해당 클라이언트를 닫습니다.

LUMIE_BACKEND_URLLUMIE_INTERNAL_HMAC_SECRET은 Joossameng AI 리포트 경로가 백엔드 batch 데이터를 가져올 때만 필요합니다. JOOSSAMENG_DATABASE_DSN 이 없으면 AI 리포트 캐시는 인메모리 process storage로 fallback합니다.

관측성과 실패 처리

analysis 워커는 다음을 내보냅니다.

  • analysis_llm_requests_total
  • analysis_llm_duration_seconds
  • analysis_llm_inflight

메트릭은 작업 단위로 라벨링됩니다.

  • exam_commentary
  • student_feedback

실패는 다음과 같이 나뉩니다.

  • 상위 OpenAI 호환 API 실패용 api_error
  • 로컬 버그 또는 예기치 못한 런타임 오류용 handler_failure

Tracing은 공유 워커 observability 헬퍼를 통해 활성화됩니다. FastAPI와 httpx가 계측되므로, 별도의 클라이언트 코드 없이 외부 LLM 트래픽이 추적됩니다.

실패 의미

실패결과
잘못된 JSON 또는 필수 필드 누락FastAPI/Pydantic 검증 오류
OpenAI 호환 API가 APIError 발생metric 결과 api_error, 라우트는 502 반환
로컬 버그 또는 예기치 못한 런타임 오류metric 결과 handler_failure, 라우트는 500 반환
비어 있는 LLM 메시지 내용content를 가진 유효한 GenerationResponse
joossameng이 아닌 tenant가 AI 리포트 라우트 호출joossameng tenant only와 함께 403
AI 리포트 작업에 성공한 PDF가 없음job status FAILED
완료 전 AI 리포트 ZIP 요청job is not ready와 함께 409

이 서비스는 api_errorhandler_failure를 의도적으로 분리하여, 대시보드가 상위 LLM 문제와 로컬 워커 결함을 구분할 수 있게 합니다.

운영 메모

  • GET /health{"status":"ok"}를 반환합니다.
  • GET /는 기본 확인용 간단한 liveness 페이로드를 반환합니다.
  • 일부 환경에서 웹 클라이언트가 직접 호출하도록 설계되어 있으므로 CORS가 활성화되어 있습니다.
  • LLM_API_KEY에는 기본값이 없으며, 누락 시 시작 실패가 나야 합니다.
  • 단위 테스트는 LLM 포트를 모킹하고 네트워크 호출 없이 오케스트레이션 동작을 검증합니다.
  • Joossameng AI 리포트 작업은 process memory에서 추적됩니다. worker가 재시작되면 작업 상태와 ZIP 바이트가 사라집니다.

검증

cd lumie-worker
uv run pytest services/analysis/tests
cd /path/to/Lumie
rg -n "exam-commentary|student-feedback|joossameng|analysis_llm" lumie-worker/services/analysis

예상 성공 신호:

  • pytest0으로 종료
  • services/analysis/tests/test_analysis_usecase.py가 코멘터리, 학생 피드백, 빈 콘텐츠 케이스를 통과
  • services/analysis/tests/test_joossameng_renderer.py가 번들 폰트, 폭 기준 줄바꿈, 공백 보존, 페이지 분할 케이스를 통과
  • services/analysis/tests/test_joossameng_service.py가 PDF 렌더링과 ZIP 쓰기가 asyncio.to_thread(...)를 통해 실행되는지 확인
  • services/analysis/tests/test_observability.py가 tracing과 /metrics smoke 테스트를 통과
  • grep이 services/analysis/main.py의 두 analysis HTTP 라우트, services/analysis/src/joossameng/ 아래의 Joossameng AI 리포트 router, services/analysis/src/ 아래의 analysis_llm_* metric 이름을 보여줌