feat: 프로필 알고리즘 설명 + 시험목표 삭제 + PDF 파싱 버그 수정 + README 재작성
- 프로필 학습설정에 현재 페르소나/강도 기반 복습 알고리즘 설명 카드 추가 - 시험 목표/집중 단원/BOJ 핸들 섹션 제거 - PDF 문제 분리: codex Vision OCR → pdftotext 텍스트 파싱으로 교체 (운영서버 호환) - README.md 전면 재작성 (최신 기능 반영) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
300
README.md
300
README.md
@@ -1,248 +1,196 @@
|
||||
# ReLoop v2
|
||||
|
||||
**잊기 전에 다시 만나는 학습법.** 페르소나 기반 망각 곡선으로 개인화된 복습 간격을 자동으로 계산하는 적응형 학습 플랫폼.
|
||||
|
||||
수능, 자격증, 어학, 취미 학습 — 어떤 공부든 ReLoop이 기억을 관리합니다.
|
||||
**잊기 전에 다시 만나는 학습법.** 에빙하우스 망각곡선 + SM-2 알고리즘으로 개인화된 복습 간격을 자동 계산하는 수학 학습 플랫폼.
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| **적응형 복습 스케줄링** | 페르소나 + 문제 난이도에 따라 "정답률이 기준 아래로 떨어지는 시점"을 자동 계산해 복습 큐에 넣습니다 |
|
||||
| **KICE 기출 문제집** | 2025/2026 수능 수학 4단원(공통/확률과 통계/미적분/기하) 92문항을 PDF에서 자동 크롭한 이미지로 제공 |
|
||||
| **모의고사 모드** | 실제 시험처럼 타이머 + OMR 답안 선택 + 자동 채점 + 학습 기록 일괄 생성 |
|
||||
| **Solved.ac PS 연동** | Solved.ac 계정에서 최신 백준 풀이 이력을 동기화해 복습 큐에 추가 |
|
||||
| **통계 대시보드** | 학습 히트맵, 단원별·PS 정답률, 숙련도 성장 곡선을 시각화 |
|
||||
| **PWA 지원** | 모바일 홈 화면에 앱으로 설치 가능 |
|
||||
| **적응형 복습 스케줄링** | 4가지 학습자 페르소나 + 3단계 자기평가로 최적의 복습 시점을 자동 계산 |
|
||||
| **복습 캘린더** | 월간 달력에서 날짜별 복습 현황 확인, 밀린 복습 자동 이월 |
|
||||
| **KICE 기출 문제집** | 수능/모의고사 수학 92문항, PDF 자동 크롭 이미지 제공 |
|
||||
| **PDF 문제 분리** | 모의고사 PDF 업로드 → 텍스트 파싱으로 문제 단위 분리 → 태그 배정 → 복습 큐 등록 |
|
||||
| **모의고사 모드** | 타이머 + OMR 답안 선택 + 자동 채점 + 학습 기록 일괄 생성 |
|
||||
| **3단계 자기평가** | 어려움/보통/쉬움 → SM-2 기반 간격 배정 (hard=1일, easy=점점 벌어짐) |
|
||||
| **필기모드** | Canvas 오버레이, 펜 3색, 문제별 localStorage 저장 |
|
||||
| **B2B QR 등록** | 종이 문제집 QR 코드 → 앱 ebook 자동 등록 |
|
||||
| **학교/학원 관리** | 학급 생성, 과제 배정, 학생별 리포트 |
|
||||
| **통계 대시보드** | 단원별 정답률, 숙련도 성장 곡선, 주간 트렌드 |
|
||||
|
||||
## 알고리즘
|
||||
|
||||
ReLoop은 두 가지 복습 경로를 제공합니다.
|
||||
|
||||
### 1. 페르소나 망각곡선 (기본)
|
||||
|
||||
```
|
||||
S(t) = S₀ × exp(-λ × t) 실력 감쇄
|
||||
P = sigmoid(k × (S(t) - D)) 정답 확률
|
||||
```
|
||||
|
||||
정답 확률 P가 기준(P_threshold) 아래로 떨어지는 시점을 다음 복습일로 설정합니다.
|
||||
|
||||
| 페르소나 | λ (망각속도) | 간격 배율 | 설명 |
|
||||
|---------|-------------|----------|------|
|
||||
| 상위권 (senior) | 0.1 | ×1.3 | 느린 망각, 넓은 복습 간격 |
|
||||
| 중위권 (mid) | 0.2 | ×1.0 | 표준 설정 |
|
||||
| 하위권 (junior) | 0.4 | ×0.8 | 빠른 망각, 조밀한 복습 |
|
||||
| 벼락치기형 (crammer) | 0.6 | ×0.6 | 초단기 집중 복습 |
|
||||
|
||||
| 학습 강도 | P_threshold | 의미 |
|
||||
|----------|-------------|------|
|
||||
| 엄격 | 0.70 | 아직 70%일 때 미리 복습 |
|
||||
| 균형 | 0.50 | 반반일 때 복습 (기본) |
|
||||
| 여유 | 0.35 | 많이 까먹은 후 복습 |
|
||||
|
||||
### 2. SM-2 하이브리드 (자기평가 시)
|
||||
|
||||
복습 시 어려움/보통/쉬움을 선택하면 SM-2 알고리즘이 적용됩니다.
|
||||
|
||||
- **어려움** → 1일 후 재복습, EF -0.3
|
||||
- **보통** → 현재 간격 유지
|
||||
- **쉬움** → 간격 확대, EF +0.15
|
||||
- 간격에 페르소나 배율이 곱해짐 (최대 60일 cap)
|
||||
|
||||
## 기술 스택
|
||||
|
||||
| 영역 | 기술 |
|
||||
|------|------|
|
||||
| Backend | NestJS 10 + Prisma 5 + MariaDB |
|
||||
| Frontend | Next.js 14 (App Router) + React 18 + styled-components 6 |
|
||||
| Backend | NestJS 10, Prisma 5, MariaDB |
|
||||
| Frontend | Next.js 14 (App Router), React 18, styled-components 6 |
|
||||
| 인증 | JWT (passport-jwt) |
|
||||
| 보안 | helmet, @nestjs/throttler (60req/min), CORS whitelist |
|
||||
| 차트 | Recharts |
|
||||
| 아이콘 | Phosphor React |
|
||||
| 폰트 | Pretendard + Inter + JetBrains Mono |
|
||||
| 배포 | PM2 on Debian VM, nginx reverse proxy |
|
||||
| PDF 처리 | poppler-utils (pdftotext, pdftoppm, pdfinfo) |
|
||||
| OCR | Codex CLI Vision (codex exec -i) |
|
||||
| PDF 파싱 | poppler-utils (pdftotext, pdftoppm, pdfinfo) |
|
||||
| 배포 | PM2, nginx reverse proxy |
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
reloop-v2/
|
||||
├── backend/ # NestJS API 서버
|
||||
│ ├── src/
|
||||
│ │ ├── auth/ # JWT 인증 (login, register, me)
|
||||
│ │ ├── dashboard/ # 대시보드 요약 API
|
||||
│ │ ├── forget/ # 페르소나 망각 곡선 엔진
|
||||
│ │ ├── me/ # 프로필, 아바타 업로드
|
||||
│ │ ├── problem-sets/ # 문제집 CRUD + 파싱 모듈
|
||||
│ │ │ └── parsing/ # PDF 파싱 엔진 (범용, 전략 패턴)
|
||||
│ │ │ ├── strategies/ # kice.ts, math.ts
|
||||
│ │ │ └── ocr-fallback/ # Codex Vision OCR
|
||||
│ │ ├── reviews/ # 복습 큐 관리
|
||||
│ │ ├── stats/ # 통계 API
|
||||
│ │ ├── study-logs/ # 학습 기록 + 모의고사 일괄 제출
|
||||
│ │ ├── subjects/ # 과목 관리
|
||||
│ │ └── tags/ # 태그 관리
|
||||
│ ├── prisma/
|
||||
│ │ ├── schema.prisma # DB 스키마
|
||||
│ │ ├── seed.ts # 데모 데이터 시드
|
||||
│ │ └── migrations/ # 마이그레이션 히스토리
|
||||
│ ├── scripts/
|
||||
│ │ ├── kice-import.ts # KICE PDF 임포트 CLI
|
||||
│ │ └── ocr-fix.ts # OCR 보정 CLI
|
||||
│ └── uploads/ # 문제 이미지, 아바타 (git 제외)
|
||||
├── frontend/ # Next.js 프론트엔드
|
||||
│ ├── src/
|
||||
│ │ ├── app/ # App Router 페이지들
|
||||
│ │ │ ├── dashboard/ # 대시보드 (채움/빈 상태)
|
||||
│ │ │ ├── exams/ # 문제집 카탈로그
|
||||
│ │ │ ├── study/
|
||||
│ │ │ │ ├── exam/[id]/ # 모의고사 시험 화면
|
||||
│ │ │ │ └── history/ # 학습 기록 히스토리
|
||||
│ │ │ ├── review/ # 복습 세션
|
||||
│ │ │ ├── solve/[id]/ # 개별 문제 풀기
|
||||
│ │ │ ├── study-logs/[id]/ # 학습 기록 상세
|
||||
│ │ │ ├── stats/ # 통계
|
||||
│ │ │ ├── subjects/ # 과목 및 태그 관리
|
||||
│ │ │ ├── profile/ # 프로필 + 설정 통합
|
||||
│ │ │ ├── pricing/ # 플랜 (공개/로그인 분기)
|
||||
│ │ │ ├── onboarding/ # 신규 사용자 온보딩
|
||||
│ │ │ ├── login/ + register/ # 인증
|
||||
│ │ │ ├── privacy/ terms/ contact/ # 법적 페이지
|
||||
│ │ │ └── page.tsx # 랜딩 페이지
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── ui/ # Button, Card, Icon, Toast, Select, DatePicker, ...
|
||||
│ │ │ ├── layout/ # AppShell, SideNav, BottomNav
|
||||
│ │ │ ├── auth/ # AuthCard (로그인/회원가입 통합)
|
||||
│ │ │ ├── dashboard/ # EmptyDashboard
|
||||
│ │ │ └── legal/ # LegalLayout
|
||||
│ │ ├── lib/ # api.ts, auth.ts, constants.ts, exam-date.ts
|
||||
│ │ └── styles/ # theme.ts (디자인 토큰, 애니메이션)
|
||||
│ └── public/ # 정적 파일, PWA manifest, 아이콘
|
||||
├── data/kice/ # KICE 원본 PDF (git 제외, 저작권)
|
||||
│ ├── 2025/{국어,수학,영어,...}/
|
||||
│ └── 2026/{국어,수학,영어,...}/
|
||||
├── docs/ # 상세 매뉴얼
|
||||
│ ├── algorithm.md # 망각 곡선 알고리즘 상세
|
||||
│ ├── architecture.md # 시스템 아키텍처
|
||||
│ ├── user-guide.md # 사용자 가이드
|
||||
│ ├── admin-guide.md # 운영/배포 가이드
|
||||
│ └── pdf-pipeline.md # PDF 파싱 파이프라인
|
||||
└── Plans.md # 개발 계획 + 진행 상황
|
||||
├── backend/
|
||||
│ └── src/
|
||||
│ ├── auth/ # JWT 인증
|
||||
│ ├── dashboard/ # 대시보드 요약
|
||||
│ ├── forget/ # 망각곡선 + SM-2 엔진
|
||||
│ ├── me/ # 프로필 (페르소나 변경 시 스케줄 재계산)
|
||||
│ ├── problem-sets/ # 문제집 + PDF 파싱
|
||||
│ │ └── parsing/ # pdftotext 기반 파서 + OCR fallback
|
||||
│ ├── reviews/ # 복습 큐 (자동 이월 포함)
|
||||
│ ├── stats/ # 통계
|
||||
│ ├── study-logs/ # 학습 기록 + PDF 문제 분리
|
||||
│ ├── subjects/ # 과목
|
||||
│ ├── tags/ # 태그
|
||||
│ ├── organizations/ # 학교/학원
|
||||
│ ├── classes/ # 학급
|
||||
│ └── assignments/ # 과제
|
||||
├── frontend/
|
||||
│ └── src/
|
||||
│ ├── app/
|
||||
│ │ ├── dashboard/ # 대시보드 (캘린더, 숙련도)
|
||||
│ │ ├── review/ # 복습 세션 (키보드 단축키)
|
||||
│ │ ├── exams/ # 문제집 카탈로그
|
||||
│ │ ├── study/
|
||||
│ │ │ ├── exam/ # 모의고사 화면
|
||||
│ │ │ ├── import-pdf/# PDF 문제 분리
|
||||
│ │ │ └── history/ # 학습 기록
|
||||
│ │ ├── stats/ # 통계
|
||||
│ │ ├── subjects/ # 과목/태그 관리
|
||||
│ │ ├── profile/ # 프로필 + 학습설정 + 알고리즘 설명
|
||||
│ │ └── school/ # 학교 관리
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # 공통 UI 컴포넌트
|
||||
│ │ ├── layout/ # AppShell, SideNav, BottomNav
|
||||
│ │ └── charts/ # 망각곡선, 숙련도 차트
|
||||
│ └── lib/ # API 클라이언트, 상수, 인증
|
||||
└── prisma/
|
||||
└── schema.prisma # DB 스키마
|
||||
```
|
||||
|
||||
## 알고리즘 요약
|
||||
|
||||
> 상세 내용: [docs/algorithm.md](docs/algorithm.md)
|
||||
|
||||
ReLoop은 **FSRS 대신 자체 페르소나 기반 망각 곡선**을 사용합니다.
|
||||
|
||||
### 핵심 수식
|
||||
|
||||
```
|
||||
실력 감쇄: S(t) = S₀ × exp(-λ × t)
|
||||
정답 확률: P = sigmoid(k × (S(t) - D))
|
||||
```
|
||||
|
||||
- **S₀** (0~1): 마지막 학습 시점의 실력. 맞으면 올라가고 틀리면 떨어짐
|
||||
- **λ (lambda)**: 페르소나별 망각 속도. senior(0.1)은 느리게, crammer(0.6)은 빠르게 잊음
|
||||
- **D**: 문제 난이도 (0~1)
|
||||
- **k**: sigmoid 기울기 (기본 4.0)
|
||||
|
||||
### 복습 시점 결정
|
||||
|
||||
"정답 확률 P가 사용자의 기준(P_threshold) 아래로 떨어지는 시점"을 다음 복습일로 잡습니다.
|
||||
|
||||
| 복습 민감도 | P_threshold | 의미 |
|
||||
|------------|-------------|------|
|
||||
| 엄격 (strict) | 0.7 | 70% 이하로 떨어지면 복습 |
|
||||
| 균형 (moderate) | 0.5 | 50% 이하 |
|
||||
| 여유 (relaxed) | 0.35 | 35% 이하 |
|
||||
|
||||
### 페르소나
|
||||
|
||||
| 페르소나 | λ (망각속도) | 복습 주기 경향 |
|
||||
|---------|-------------|---------------|
|
||||
| Senior | 0.1 | 7~14일 (느린 망각) |
|
||||
| Mid | 0.2 | 2~5일 |
|
||||
| Junior | 0.4 | 1~2일 (빠른 망각) |
|
||||
| Crammer | 0.6 | 매일~격일 |
|
||||
|
||||
### S₀ 업데이트 규칙
|
||||
|
||||
| 결과 | 새 S₀ |
|
||||
|------|-------|
|
||||
| 정답 | S₀ × 0.5 + 0.6 |
|
||||
| 부분 | S₀ × 0.7 + 0.3 |
|
||||
| 오답 | S₀ × 0.5 |
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 필수 조건
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm 9+
|
||||
- Node.js 20+, pnpm 9+
|
||||
- MariaDB/MySQL 8+
|
||||
- poppler-utils (`pdftotext`, `pdftoppm`, `pdfinfo`)
|
||||
- poppler-utils (`apt install poppler-utils`)
|
||||
|
||||
### 설치 및 실행
|
||||
### 설치
|
||||
|
||||
```bash
|
||||
# 1. 클론
|
||||
git clone ssh://git@your-gitea:2222/hanarang/reloop-v2.git
|
||||
cd reloop-v2
|
||||
|
||||
# 2. 백엔드
|
||||
# 백엔드
|
||||
cd backend
|
||||
pnpm install
|
||||
cp .env.example .env # DATABASE_URL, JWT_SECRET 편집
|
||||
cp .env.example .env # DATABASE_URL, JWT_SECRET 편집
|
||||
pnpm prisma migrate dev
|
||||
pnpm prisma:seed # 데모 계정 생성
|
||||
pnpm dev # http://localhost:3001
|
||||
pnpm prisma:seed
|
||||
pnpm dev # :3001
|
||||
|
||||
# 3. 프론트엔드 (새 터미널)
|
||||
cd frontend
|
||||
# 프론트엔드
|
||||
cd ../frontend
|
||||
pnpm install
|
||||
cp .env.example .env.local # NEXT_PUBLIC_API_URL 편집
|
||||
pnpm dev # http://localhost:3000
|
||||
|
||||
# 4. (선택) KICE 기출 임포트
|
||||
# data/kice/ 에 PDF 배치 후:
|
||||
cd backend
|
||||
pnpm cli:kice-import --year=2026
|
||||
cp .env.example .env.local # NEXT_PUBLIC_API_URL 편집
|
||||
pnpm dev # :3000
|
||||
```
|
||||
|
||||
### 데모 계정
|
||||
### 환경 변수
|
||||
|
||||
| 이메일 | 비밀번호 |
|
||||
|--------|---------|
|
||||
| demo@reloop.local | demo1234 |
|
||||
**Backend** (`backend/.env`)
|
||||
|
||||
## 환경 변수
|
||||
| 변수 | 필수 | 설명 |
|
||||
|------|------|------|
|
||||
| `DATABASE_URL` | O | `mysql://user:pass@host:3306/reloop_v2` |
|
||||
| `JWT_SECRET` | O | 32자+ 랜덤 문자열 |
|
||||
| `JWT_EXPIRES_IN` | - | 기본 `7d` |
|
||||
| `CORS_ORIGINS` | - | 허용 도메인 (쉼표 구분) |
|
||||
| `PORT` | - | 기본 `3001` |
|
||||
|
||||
### Backend (`backend/.env`)
|
||||
**Frontend** (`frontend/.env.local`)
|
||||
|
||||
| 변수 | 필수 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `DATABASE_URL` | O | — | `mysql://user:pass@host:3306/reloop_v2` |
|
||||
| `JWT_SECRET` | O | — | 32자 이상 랜덤 문자열 |
|
||||
| `JWT_EXPIRES_IN` | — | `7d` | 토큰 만료 기간 |
|
||||
| `CORS_ORIGINS` | — | `http://localhost:3000` | 쉼표 구분 허용 도메인 |
|
||||
| `PORT` | — | `3001` | API 서버 포트 |
|
||||
|
||||
### Frontend (`frontend/.env.local`)
|
||||
|
||||
| 변수 | 필수 | 기본값 |
|
||||
|------|------|--------|
|
||||
| `NEXT_PUBLIC_API_URL` | — | `http://localhost:3001/api` |
|
||||
| 변수 | 설명 |
|
||||
|------|------|
|
||||
| `NEXT_PUBLIC_API_URL` | 기본 `http://localhost:3001/api` |
|
||||
|
||||
## 배포
|
||||
|
||||
> 상세 내용: [docs/admin-guide.md](docs/admin-guide.md)
|
||||
|
||||
```bash
|
||||
# 서버에서
|
||||
cd /path/to/reloop-v2
|
||||
|
||||
# 백엔드
|
||||
cd backend && pnpm install && pnpm prisma:deploy && pnpm build
|
||||
cd backend && rm -f *.tsbuildinfo && pnpm build
|
||||
pm2 restart reloop-api
|
||||
|
||||
# 프론트엔드
|
||||
cd frontend && pnpm install && pnpm build
|
||||
cd ../frontend && pnpm build
|
||||
cp -r .next/static .next/standalone/.next/
|
||||
cp -r public .next/standalone/
|
||||
pm2 restart reloop-web
|
||||
```
|
||||
|
||||
## API 요약
|
||||
|
||||
> 상세 내용: [docs/architecture.md](docs/architecture.md)
|
||||
## API 엔드포인트
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/auth/register` | 회원가입 |
|
||||
| POST | `/api/auth/login` | 로그인 (JWT 발급) |
|
||||
| POST | `/api/auth/login` | 로그인 |
|
||||
| GET | `/api/auth/me` | 내 정보 |
|
||||
| PATCH | `/api/me/profile` | 프로필 수정 |
|
||||
| POST | `/api/me/avatar` | 아바타 업로드 |
|
||||
| PATCH | `/api/me/profile` | 프로필 수정 (페르소나 변경 시 스케줄 재계산) |
|
||||
| GET | `/api/dashboard/summary` | 대시보드 요약 |
|
||||
| GET | `/api/problem-sets` | 문제집 목록 |
|
||||
| GET | `/api/problem-sets/:id` | 문제집 상세 (문항 + 지문) |
|
||||
| GET | `/api/reviews/queue` | 복습 큐 (밀린 복습 자동 이월) |
|
||||
| POST | `/api/reviews/:id/submit` | 복습 결과 제출 |
|
||||
| GET | `/api/reviews/calendar` | 월간 복습 캘린더 |
|
||||
| GET | `/api/reviews/day` | 일별 복습 상세 |
|
||||
| GET | `/api/study-logs` | 학습 기록 목록 |
|
||||
| POST | `/api/study-logs` | 학습 기록 생성 |
|
||||
| GET | `/api/study-logs/:id` | 학습 기록 상세 |
|
||||
| PATCH | `/api/study-logs/:id` | 학습 기록 수정 |
|
||||
| POST | `/api/study-logs/from-problem-set` | 모의고사 일괄 제출 |
|
||||
| GET | `/api/reviews/queue` | 오늘의 복습 큐 |
|
||||
| POST | `/api/reviews/:id/submit` | 복습 결과 제출 |
|
||||
| POST | `/api/study-logs/parse-pdf` | PDF 문제 분리 (OCR) |
|
||||
| POST | `/api/study-logs/import-from-pdf` | 분리된 문제 등록 |
|
||||
| GET | `/api/problem-sets` | 문제집 목록 |
|
||||
| GET | `/api/subjects` | 과목 목록 |
|
||||
| GET | `/api/stats/subjects` | 과목별 통계 |
|
||||
| GET | `/api/stats/mastery-path` | 숙련도 성장 곡선 |
|
||||
|
||||
## 라이선스
|
||||
|
||||
@@ -289,7 +289,9 @@ export class StudyLogsService {
|
||||
}
|
||||
|
||||
async parsePdfToProblems(pdfPath: string, title: string) {
|
||||
const { ocrProblemPage } = await import('../problem-sets/parsing/ocr-fallback/index');
|
||||
const { extractText } = await import('../problem-sets/parsing/extract-text');
|
||||
const { parseProblemPaper } = await import('../problem-sets/parsing/parse-problem-paper');
|
||||
const { kiceMathStrategy } = await import('../problem-sets/parsing/strategies/math');
|
||||
const { execFileSync } = await import('child_process');
|
||||
|
||||
let pageCount = 1;
|
||||
@@ -298,32 +300,30 @@ export class StudyLogsService {
|
||||
const match = output.match(/^Pages:\s+(\d+)/m);
|
||||
if (match) pageCount = Number(match[1]);
|
||||
} catch {
|
||||
// pdfinfo 미설치 시 1페이지로 시도
|
||||
// pdfinfo 미설치 시 fallback
|
||||
}
|
||||
|
||||
const allProblems: Array<{ number: number; bodyText: string; choices: Record<string, string> }> = [];
|
||||
|
||||
for (let page = 1; page <= pageCount; page++) {
|
||||
try {
|
||||
const problems = await ocrProblemPage(pdfPath, page);
|
||||
allProblems.push(...problems);
|
||||
} catch {
|
||||
// 페이지 파싱 실패 시 건너뜀
|
||||
}
|
||||
// pdftotext 로 텍스트 추출 → 문제 파싱 (codex CLI 불필요)
|
||||
let rawText = '';
|
||||
try {
|
||||
rawText = extractText(pdfPath, 'raw');
|
||||
} catch {
|
||||
throw new BadRequestException('PDF에서 텍스트를 추출할 수 없어. 이미지 전용 PDF는 아직 지원하지 않아.');
|
||||
}
|
||||
|
||||
// 중복 번호 제거 (첫 번째 유지)
|
||||
const seen = new Set<number>();
|
||||
const unique = allProblems.filter((p) => {
|
||||
if (seen.has(p.number)) return false;
|
||||
seen.add(p.number);
|
||||
return true;
|
||||
});
|
||||
const parsed = parseProblemPaper(rawText, kiceMathStrategy);
|
||||
|
||||
const problems = parsed.problems.map((p) => ({
|
||||
number: p.number,
|
||||
bodyText: p.bodyText,
|
||||
choices: p.choices,
|
||||
}));
|
||||
|
||||
return {
|
||||
title,
|
||||
problems: unique.sort((a, b) => a.number - b.number),
|
||||
problems,
|
||||
totalPages: pageCount,
|
||||
warnings: parsed.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,7 @@ import { Button, Card } from '@/components/ui/primitives';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
api,
|
||||
MATH_UNIT_LABEL,
|
||||
resolveAssetUrl,
|
||||
type MathUnit,
|
||||
type MeUser,
|
||||
type Persona,
|
||||
type ReviewIntensity,
|
||||
@@ -50,10 +48,8 @@ type ProfilePatch = Partial<
|
||||
> & { onboarded?: boolean }
|
||||
>;
|
||||
|
||||
const BOJ_HANDLE_REGEX = /^[a-zA-Z0-9_-]{3,20}$/;
|
||||
const NOTIFICATION_STORAGE_KEY = 'reloop:profile-notifications';
|
||||
const FETCH_LIMIT = 200;
|
||||
const MATH_UNIT_OPTIONS = Object.entries(MATH_UNIT_LABEL) as Array<[MathUnit, string]>;
|
||||
const DEFAULT_NOTIFICATIONS: NotificationPrefs = {
|
||||
pushDailyReview: true,
|
||||
weeklyReport: true,
|
||||
@@ -109,8 +105,6 @@ function ProfileBody() {
|
||||
const [isEditingNickname, setIsEditingNickname] = useState(false);
|
||||
const [currentGradeDraft, setCurrentGradeDraft] = useState(5);
|
||||
const [targetGradeDraft, setTargetGradeDraft] = useState(3);
|
||||
const [targetExamYearDraft, setTargetExamYearDraft] = useState(new Date().getFullYear() + 1);
|
||||
const [bojHandleDraft, setBojHandleDraft] = useState('');
|
||||
const [notifications, setNotifications] =
|
||||
useState<NotificationPrefs>(DEFAULT_NOTIFICATIONS);
|
||||
const [studyLogs, setStudyLogs] = useState<StudyLog[]>([]);
|
||||
@@ -196,8 +190,8 @@ function ProfileBody() {
|
||||
setNicknameDraft(nextUser.nickname);
|
||||
setCurrentGradeDraft(nextUser.currentGrade ?? 5);
|
||||
setTargetGradeDraft(nextUser.targetGrade ?? 3);
|
||||
setTargetExamYearDraft(nextUser.targetExamYear ?? new Date().getFullYear() + 1);
|
||||
setBojHandleDraft(nextUser.bojHandle ?? '');
|
||||
|
||||
|
||||
setAvatarPreviewUrl(null);
|
||||
setStudyLogs(logs);
|
||||
} finally {
|
||||
@@ -221,16 +215,13 @@ function ProfileBody() {
|
||||
setNicknameDraft(nextUser.nickname);
|
||||
setCurrentGradeDraft(nextUser.currentGrade ?? currentGradeRef.current);
|
||||
setTargetGradeDraft(nextUser.targetGrade ?? targetGradeRef.current);
|
||||
setTargetExamYearDraft(nextUser.targetExamYear ?? targetExamYearDraft);
|
||||
setBojHandleDraft(nextUser.bojHandle ?? '');
|
||||
|
||||
showToast({ message: successMessage, variant: 'success' });
|
||||
} catch {
|
||||
const currentUser = userRef.current;
|
||||
setNicknameDraft(currentUser?.nickname ?? '');
|
||||
setCurrentGradeDraft(currentUser?.currentGrade ?? 5);
|
||||
setTargetGradeDraft(currentUser?.targetGrade ?? 3);
|
||||
setTargetExamYearDraft(currentUser?.targetExamYear ?? new Date().getFullYear() + 1);
|
||||
setBojHandleDraft(currentUser?.bojHandle ?? '');
|
||||
showToast({ message: '저장에 실패했습니다', variant: 'danger' });
|
||||
} finally {
|
||||
setSavingKey((current) => (current === key ? null : current));
|
||||
@@ -312,46 +303,7 @@ function ProfileBody() {
|
||||
void patchProfile({ reviewIntensity: value }, 'intensity', '학습강도 변경됨 — 복습 스케줄을 새 설정에 맞게 재계산했어요');
|
||||
}
|
||||
|
||||
function handleTargetExamYearSave() {
|
||||
if (!user) return;
|
||||
if (targetExamYearDraft === user.targetExamYear) return;
|
||||
void patchProfile({ targetExamYear: targetExamYearDraft }, 'target-year');
|
||||
}
|
||||
|
||||
function handleBojHandleSave() {
|
||||
if (!user) return;
|
||||
const trimmed = bojHandleDraft.trim();
|
||||
const currentHandle = user.bojHandle ?? '';
|
||||
if (trimmed === currentHandle) return;
|
||||
if (trimmed && !BOJ_HANDLE_REGEX.test(trimmed)) {
|
||||
showToast({ message: 'BOJ 핸들을 다시 확인해 주세요.', variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
void patchProfile(
|
||||
{ bojHandle: trimmed ? trimmed : null },
|
||||
'boj-handle',
|
||||
trimmed ? 'BOJ 핸들을 저장했어요' : 'BOJ 핸들을 제거했어요',
|
||||
);
|
||||
}
|
||||
|
||||
function toggleFocusUnit(unit: MathUnit) {
|
||||
if (!user) return;
|
||||
|
||||
const current = new Set(user.focusUnits ?? []);
|
||||
if (current.has(unit)) {
|
||||
if (current.size === 1) {
|
||||
showToast({ message: '최소 1개 단원은 유지해야 합니다', variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
current.delete(unit);
|
||||
} else {
|
||||
current.add(unit);
|
||||
}
|
||||
|
||||
const next = Array.from(current);
|
||||
setUser((prev) => (prev ? { ...prev, focusUnits: next } : prev));
|
||||
void patchProfile({ focusUnits: next }, 'focus-units', '집중 단원을 업데이트했습니다');
|
||||
}
|
||||
|
||||
function handleNotificationToggle(key: keyof NotificationPrefs) {
|
||||
setNotifications((current) => ({
|
||||
@@ -597,7 +549,6 @@ function ProfileBody() {
|
||||
<BadgeRow>
|
||||
<InfoBadge>현재 등급 {currentGradeDraft}등급</InfoBadge>
|
||||
<InfoBadge>목표 등급 {targetGradeDraft}등급</InfoBadge>
|
||||
<InfoBadge>목표 학년도 {targetExamYearDraft}학년도</InfoBadge>
|
||||
</BadgeRow>
|
||||
</AccountContent>
|
||||
</AccountRow>
|
||||
@@ -683,55 +634,60 @@ function ProfileBody() {
|
||||
|
||||
<CardSection>
|
||||
<SectionHeader>
|
||||
<SectionTitle>시험 목표 / 집중 단원</SectionTitle>
|
||||
<SectionTitle>현재 복습 알고리즘</SectionTitle>
|
||||
</SectionHeader>
|
||||
<FieldRow>
|
||||
<FieldLabel>목표 학년도</FieldLabel>
|
||||
<InlineField>
|
||||
<YearInput
|
||||
type="number"
|
||||
value={targetExamYearDraft}
|
||||
onChange={(event) => setTargetExamYearDraft(Number(event.target.value))}
|
||||
/>
|
||||
<Button type="button" onClick={handleTargetExamYearSave}>
|
||||
저장
|
||||
</Button>
|
||||
</InlineField>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<FieldLabel>집중 단원</FieldLabel>
|
||||
<UnitGrid>
|
||||
{MATH_UNIT_OPTIONS.map(([value, label]) => {
|
||||
const active = (user.focusUnits ?? []).includes(value);
|
||||
return (
|
||||
<UnitPill
|
||||
key={value}
|
||||
type="button"
|
||||
$active={active}
|
||||
onClick={() => toggleFocusUnit(value)}
|
||||
>
|
||||
<Icon name={active ? 'check-circle' : 'circle'} size={15} />
|
||||
{label}
|
||||
</UnitPill>
|
||||
);
|
||||
})}
|
||||
</UnitGrid>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<FieldLabel>BOJ 핸들</FieldLabel>
|
||||
<InlineField>
|
||||
<HandleInput
|
||||
value={bojHandleDraft}
|
||||
onChange={(event) => setBojHandleDraft(event.target.value)}
|
||||
placeholder="예) reloop_ps"
|
||||
maxLength={20}
|
||||
/>
|
||||
<Button type="button" onClick={handleBojHandleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</InlineField>
|
||||
<FieldNote>영문/숫자/-/_ 3~20자. PS 동기화에 사용됩니다.</FieldNote>
|
||||
</FieldRow>
|
||||
<AlgorithmExplainer>
|
||||
<AlgoSection>
|
||||
<AlgoLabel>적용 중인 설정</AlgoLabel>
|
||||
<AlgoValue>
|
||||
{PERSONA_META[user.persona].emoji} {PERSONA_META[user.persona].label} 페르소나
|
||||
{' + '}
|
||||
{INTENSITY_CARDS.find((c) => c.value === user.reviewIntensity)?.label ?? '균형'} 강도
|
||||
</AlgoValue>
|
||||
</AlgoSection>
|
||||
|
||||
<AlgoSection>
|
||||
<AlgoLabel>알고리즘 동작 방식</AlgoLabel>
|
||||
<AlgoDesc>
|
||||
ReLoop은 <strong>에빙하우스 망각곡선</strong>과 <strong>SM-2 알고리즘</strong>을 결합해 복습 시점을 계산해.
|
||||
</AlgoDesc>
|
||||
<AlgoDesc>
|
||||
문제를 풀면 실력 점수(S₀)가 업데이트되고, 시간이 지나면서 기억이 감소해.
|
||||
정답 확률이 기준 아래로 떨어지는 시점에 복습이 잡혀.
|
||||
</AlgoDesc>
|
||||
</AlgoSection>
|
||||
|
||||
<AlgoSection>
|
||||
<AlgoLabel>페르소나 효과</AlgoLabel>
|
||||
<AlgoDesc>
|
||||
<strong>{PERSONA_META[user.persona].label}</strong> — 망각 속도 λ = {PERSONA_META[user.persona].lambda}/일.{' '}
|
||||
{user.persona === 'senior' && '기억 유지력이 높아 복습 간격이 넓어.'}
|
||||
{user.persona === 'mid' && '평균적인 망각 속도로, 꾸준한 복습이 효과적이야.'}
|
||||
{user.persona === 'junior' && '빠르게 잊기 때문에 더 자주 복습이 잡혀.'}
|
||||
{user.persona === 'crammer' && '단기 집중형이라 가장 짧은 간격으로 자주 복습해.'}
|
||||
</AlgoDesc>
|
||||
</AlgoSection>
|
||||
|
||||
<AlgoSection>
|
||||
<AlgoLabel>학습 강도 효과</AlgoLabel>
|
||||
<AlgoDesc>
|
||||
{user.reviewIntensity === 'strict' && '정답 확률 70% 이하로 떨어지면 바로 복습 — 가장 빡빡한 설정이야.'}
|
||||
{user.reviewIntensity === 'moderate' && '정답 확률 50% 이하에서 복습 — 균형 잡힌 기본 설정이야.'}
|
||||
{user.reviewIntensity === 'relaxed' && '정답 확률 35% 이하에서 복습 — 간격이 넓어 부담이 적어.'}
|
||||
</AlgoDesc>
|
||||
</AlgoSection>
|
||||
|
||||
<AlgoSection>
|
||||
<AlgoLabel>3단계 자기평가 (복습 시)</AlgoLabel>
|
||||
<AlgoDesc>
|
||||
복습할 때 <strong>어려움/보통/쉬움</strong>을 선택하면 SM-2 알고리즘이 적용돼.{' '}
|
||||
어려움 → 1일 후 재복습, 보통 → 현재 간격 유지, 쉬움 → 간격이 점점 벌어져.
|
||||
{user.persona === 'senior' && ' 상위권은 간격이 ×1.3배 더 넓게 적용돼.'}
|
||||
{user.persona === 'junior' && ' 하위권은 간격이 ×0.8배로 조밀하게 적용돼.'}
|
||||
{user.persona === 'crammer' && ' 벼락치기형은 간격이 ×0.6배로 가장 조밀해.'}
|
||||
</AlgoDesc>
|
||||
</AlgoSection>
|
||||
</AlgorithmExplainer>
|
||||
</CardSection>
|
||||
</Grid>
|
||||
) : null}
|
||||
@@ -1442,3 +1398,43 @@ const LoadingState = styled(Card)`
|
||||
justify-content: center;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const AlgorithmExplainer = styled.div`
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
`;
|
||||
|
||||
const AlgoSection = styled.div`
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const AlgoLabel = styled.span`
|
||||
color: ${theme.color.textMute};
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
`;
|
||||
|
||||
const AlgoValue = styled.span`
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const AlgoDesc = styled.p`
|
||||
margin: 0;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
|
||||
strong {
|
||||
color: ${theme.color.textBright};
|
||||
font-weight: 600;
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user