feat: 문제집 점프 네비 + 학교 PDF 과제 + ALGORITHM.md 업데이트

1. 업로드 문제집 뷰어에 문제 번호 점프 스트립 추가
   - 스크롤 가능한 번호 버튼, 풀이/등록 상태별 색상 구분
2. 학교 > 새 과제에 PDF 업로드 기능 추가
   - 문제 PDF + 해설지(선택) 업로드 → 이미지 크롭 → ProblemSet 자동 생성
   - 생성된 문제집이 드롭다운에 자동 추가되어 바로 과제 배정 가능
3. ALGORITHM.md 알고리즘 변경사항 반영
   - easeFactor 필드 분리 설명
   - intensity SM-2 배율 (strict×0.7, relaxed×1.4)
   - 망각곡선 안전장치 (P<30% 시 간격 축소)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
reloop
2026-04-17 20:04:49 +09:00
parent eeb94db8e7
commit e8b8c4b6e2
3 changed files with 254 additions and 21 deletions

View File

@@ -40,7 +40,8 @@ ReLoop은 여기에 **페르소나 배율**을 곱해서 학습자 유형별로
- 최소 EF는 **1.3** (이 이하로 내려가지 않음)
- 초기 EF는 **2.5**
- EF는 `SkillSnapshot.s0` 필드에 저장됨 (태그별로 하나)
- EF는 `SkillSnapshot.easeFactor` 필드에 저장됨 (태그별로 하나)
- 기억 강도 S₀는 별도로 `SkillSnapshot.s0` (0~1)에 저장됨
**예시:**
```
@@ -62,6 +63,11 @@ ReLoop은 여기에 **페르소나 배율**을 곱해서 학습자 유형별로
중위권 (mid) = 1.0 → 기본
하위권 (junior) = 0.8 → 간격이 20% 좁아짐
벼락치기 (crammer)= 0.6 → 간격이 40% 좁아짐
학습 강도 배율 (if):
엄격 (strict) = 0.7 → 간격이 30% 좁아짐 (더 자주 복습)
균형 (moderate) = 1.0 → 기본
여유 (relaxed) = 1.4 → 간격이 40% 넓어짐 (덜 자주 복습)
```
**어려웠어 → 무조건 1일 후** (어떤 상황이든):
@@ -71,8 +77,8 @@ ReLoop은 여기에 **페르소나 배율**을 곱해서 학습자 유형별로
**처음 푸는 문제 (iteration = 0):**
```
괜찮았어 → 간격 = round(3 × pf)일
쉬웠어 → 간격 = round(7 × pf)일
괜찮았어 → 간격 = round(3 × pf × if)일
쉬웠어 → 간격 = round(7 × pf × if)일
```
| 체감 난이도 | senior (×1.3) | mid (×1.0) | junior (×0.8) | crammer (×0.6) |
@@ -90,7 +96,7 @@ ReLoop은 여기에 **페르소나 배율**을 곱해서 학습자 유형별로
**두 번째 이후 복습 (iteration >= 2):**
```
어려웠어 → 1일
그 외 → 간격 = round(6 × EF^(iteration-1) × pf)일
그 외 → 간격 = round(6 × EF^(iteration-1) × pf × if)일
```
| iteration | EF=2.5, mid(×1.0) | EF=2.5, senior(×1.3) | EF=1.5, mid(×1.0) |
@@ -118,7 +124,7 @@ ReLoop은 여기에 **페르소나 배율**을 곱해서 학습자 유형별로
5. ReviewSchedule 생성 (scheduledAt = 지금 + 1일)
6. SkillSnapshot.s0에 새 EF 저장
6. SkillSnapshot.easeFactor에 새 EF, s0에 기억 강도 저장
[1일 후 복습 큐에 등장]
@@ -126,15 +132,36 @@ ReLoop은 여기에 **페르소나 배율**을 곱해서 학습자 유형별로
1. EF = max(1.3, 이전EF + 0.15)
2. 간격 = round(6 × EF^(1-1) × pf) = round(6 × 1 × pf) = 6일 (mid 기준)
2. 간격 = round(6 × EF^(1-1) × pf × if) = 6일 (mid+moderate 기준)
3. 새 ReviewSchedule (scheduledAt = 지금 + 6일)
3. 망각곡선 보정: 6일 후 예상 P 시뮬레이션
[6일 후 다시 복습 큐에 등장]
4. P >= 30%이면 6일 유지, P < 30%이면 안전 간격으로 축소
5. 새 ReviewSchedule (scheduledAt = 보정된 간격)
[n일 후 다시 복습 큐에 등장]
... 반복 (iteration 증가 → EF 지수승 → 간격 기하급수적 확대)
```
### 1.5 망각곡선 안전장치
SM-2가 계산한 간격이 너무 길어서 그 시점에 기억이 거의 사라졌을 가능성을 방지한다:
```
1. SM-2가 간격 N일을 계산
2. 망각곡선으로 N일 후 기억 강도 시뮬레이션:
futureS = s0 × e^(-λ × N)
futureP = sigmoid(k × (futureS - D))
3. futureP < 0.3 (30%)이면:
- 정답 확률 50%가 되는 시점을 역산
- 그 시점으로 간격 축소
4. futureP >= 0.3이면: SM-2 간격 그대로 사용
```
이 보정은 **벼락치기형(λ=0.6)처럼 빠르게 잊는 사용자**에게 특히 효과적. SM-2가 6일을 계산해도 실제로 3일 만에 기억이 30% 이하로 떨어진다면 3일로 줄여준다.
---
## 경로 2: 에빙하우스 망각곡선 (Legacy)
@@ -244,7 +271,15 @@ t = -(1/0.6) × ln(0.5 / 0.75)
벼락치기 ×0.6 → 모든 간격이 40% 좁아짐
```
학습 강도(reviewIntensity)는 **SM-2에 영향 없음**.
학습 강도(reviewIntensity)는 **INTENSITY_SM2_FACTOR 배율**로 영향:
```
엄격 ×0.7 → 모든 간격이 30% 좁아짐 (더 자주 복습)
균형 ×1.0 → 기본
여유 ×1.4 → 모든 간격이 40% 넓어짐 (덜 자주 복습)
```
추가로 **망각곡선 안전장치**가 작동하여 SM-2 간격 후 예상 P가 30% 이하면 자동 축소.
### 망각곡선 경로에서 (legacy)
@@ -265,16 +300,16 @@ t = -(1/0.6) × ln(0.5 / 0.75)
---
## SkillSnapshot의 이중 용도
## SkillSnapshot 필드 구조
`SkillSnapshot.s0` 필드는 **경로에 따라 다른 값을 저장**한다:
`SkillSnapshot`은 태그별로 두 개의 독립적인 값을 저장한다:
| 경로 | s0에 저장되는 값 | 범위 | 의미 |
|------|---------------|------|------|
| SM-2 | EF (Ease Factor) | 1.3 ~ 4.0+ | 높을수록 쉬운 문제 |
| 망각곡선 | 기억 강도 | 0 ~ 1.0 | 높을수록 잘 기억하는 중 |
| 필드 | 범위 | 의미 | 사용처 |
|------|------|------|--------|
| `s0` | 0 ~ 1.0 | 기억 강도 | 망각곡선 보정, 예상 P 계산 |
| `easeFactor` | 1.3 ~ 4.0+ (nullable) | SM-2 Ease Factor | SM-2 간격 계산 |
**구분 방법:** `s0 >= 1.3 && s0 <= 4.0`이면 SM-2 경로로 판단.
두 값은 **항상 동시에 업데이트**된다. SM-2 경로에서도 s0(기억 강도)를 업데이트하고, 망각곡선 보정에 사용한다. `easeFactor`가 null이면 해당 태그에서 SM-2가 아직 사용되지 않았음을 의미한다.
---
@@ -319,18 +354,26 @@ t = -(1/0.6) × ln(0.5 / 0.75)
│ │
│ 초기 간격 (iteration=0): │
│ 어려웠어 = 1일 │
│ 괜찮았어 = 3 × pf 일
│ 쉬웠어 = 7 × pf 일
│ 괜찮았어 = 3 × pf × if 일 │
│ 쉬웠어 = 7 × pf × if 일 │
│ │
│ 이후 간격: │
│ 어려웠어 = 1일 │
│ 그 외 = 6 × EF^(iter-1) × pf 일
│ 그 외 = 6 × EF^(iter-1) × pf × if 일 │
│ │
│ 망각곡선 보정: │
│ 간격 후 예상 P < 30% → 안전 간격으로 축소 │
├─────────────────────────────────────────────┤
│ PERSONA_FACTOR (SM-2 간격 배율) │
│ 상위권 = 1.3 │
│ 중위권 = 1.0 │
│ 하위권 = 0.8 │
│ 벼락치기 = 0.6 │
│ │
│ INTENSITY_SM2_FACTOR (학습 강도 배율) │
│ 엄격 = 0.7 │
│ 균형 = 1.0 │
│ 여유 = 1.4 │
├─────────────────────────────────────────────┤
│ 망각곡선 상수 │
├─────────────────────────────────────────────┤

View File

@@ -347,6 +347,27 @@ function UploadedEbookBody() {
</EvalSection>
)}
{/* 문제 번호 점프 */}
<JumpStrip>
{problems.map((p, idx) => {
const isCurrent = idx === pageIndex;
const isSolved = revealed[p.number] ?? false;
const isRegistered = registeredProblems.has(p.number);
return (
<JumpBtn
key={p.number}
type="button"
$current={isCurrent}
$solved={isSolved}
$registered={isRegistered}
onClick={() => goTo(idx)}
>
{p.number}
</JumpBtn>
);
})}
</JumpStrip>
{/* 네비게이션 */}
<Nav>
<NavBtn type="button" disabled={pageIndex === 0} onClick={() => goTo(pageIndex - 1)}>
@@ -606,6 +627,46 @@ const EvalHint = styled.span`
color: ${theme.color.textSub};
`;
const JumpStrip = styled.div`
display: flex;
gap: 6px;
overflow-x: auto;
padding: 8px 0;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar { height: 4px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
`;
const JumpBtn = styled.button<{ $current: boolean; $solved: boolean; $registered: boolean }>`
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 10px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
border: 1.5px solid ${({ $current, $solved, $registered }) =>
$current ? theme.color.brandIndigo
: $registered ? 'rgba(34,197,94,0.5)'
: $solved ? 'rgba(129,140,248,0.3)'
: theme.color.borderSoftAlpha};
background: ${({ $current, $solved, $registered }) =>
$current ? 'rgba(79,70,229,0.2)'
: $registered ? 'rgba(34,197,94,0.1)'
: $solved ? 'rgba(129,140,248,0.08)'
: 'rgba(255,255,255,0.03)'};
color: ${({ $current, $registered }) =>
$current ? '#c7d2fe'
: $registered ? '#4ade80'
: theme.color.textSub};
&:hover { background: rgba(79,70,229,0.15); color: ${theme.color.textBright}; }
`;
const Nav = styled.div`
display: flex;
align-items: center;

View File

@@ -7,14 +7,17 @@ import DatePicker from '@/components/ui/DatePicker';
import Select from '@/components/ui/Select';
import { useToast } from '@/components/ui/Toast';
import { Button, Input, Label, Textarea } from '@/components/ui/primitives';
import { Icon } from '@/components/ui/Icon';
import {
type ClassSummary,
type MeUser,
type ProblemSetSummary,
api,
createAssignment,
createProblemSetFromPdf,
getMyClasses,
getMyOrganizations,
getMyUploadedProblemSets,
getProblemSets,
} from '@/lib/api';
import { canManageSchool } from '@/lib/school';
@@ -47,6 +50,11 @@ export default function NewAssignmentPage() {
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [uploadingPdf, setUploadingPdf] = useState(false);
const pdfInputRef = React.useRef<HTMLInputElement>(null);
const answerPdfInputRef = React.useRef<HTMLInputElement>(null);
const [pdfFile, setPdfFile] = useState<File | null>(null);
const [answerPdfFile, setAnswerPdfFile] = useState<File | null>(null);
useEffect(() => {
let cancelled = false;
@@ -56,10 +64,11 @@ export default function NewAssignmentPage() {
setError(null);
try {
const [meResponse, orgs, fetchedProblemSets] = await Promise.all([
const [meResponse, orgs, fetchedProblemSets, myUploads] = await Promise.all([
api.get<MeUser>('/auth/me').then((response) => response.data),
getMyOrganizations(),
getProblemSets(),
getMyUploadedProblemSets().catch(() => []),
]);
const manageableOrgs = orgs.filter((org) => canManageSchool(org.myRole));
@@ -70,8 +79,9 @@ export default function NewAssignmentPage() {
setMe(meResponse);
setClasses(teacherClasses);
const allSets = [...fetchedProblemSets, ...myUploads];
setProblemSets(
[...fetchedProblemSets].sort((a, b) => {
allSets.sort((a, b) => {
if (b.year !== a.year) return b.year - a.year;
return a.title.localeCompare(b.title, 'ko');
}),
@@ -107,6 +117,59 @@ export default function NewAssignmentPage() {
[problemSets],
);
const handlePdfUpload = async () => {
if (!pdfFile || uploadingPdf) return;
setUploadingPdf(true);
try {
// 1. Parse PDF
const formData = new FormData();
formData.append('file', pdfFile);
if (answerPdfFile) formData.append('answerFile', answerPdfFile);
formData.append('title', pdfFile.name.replace(/\.pdf$/i, ''));
const parseRes = await api.post<{
title: string;
problems: Array<{ number: number; imageUrl: string; correctAnswer: number | null }>;
}>('/study-logs/parse-pdf', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (parseRes.data.problems.length === 0) {
showToast({ message: 'PDF에서 문제를 찾지 못했어.', variant: 'danger' });
return;
}
// 2. Create ProblemSet
const result = await createProblemSetFromPdf({
title: parseRes.data.title,
subjectName: '수학',
problems: parseRes.data.problems.map((p) => ({
number: p.number,
imageUrl: p.imageUrl,
correctAnswer: p.correctAnswer,
})),
});
// 3. Add to list and auto-select
const newPs: ProblemSetSummary = {
...result.problemSet,
problems: [],
};
setProblemSets((prev) => [newPs, ...prev]);
setProblemSetId(result.problemSet.id);
setPdfFile(null);
setAnswerPdfFile(null);
showToast({
message: `"${result.problemSet.title}" 문제집 생성 완료! (${result.problems.length}문제)`,
variant: 'success',
});
} catch {
showToast({ message: 'PDF 문제집 생성에 실패했어.', variant: 'danger' });
} finally {
setUploadingPdf(false);
}
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!canSubmit || classId === null || problemSetId === null) return;
@@ -192,6 +255,30 @@ export default function NewAssignmentPage() {
/>
</Field>
<Field $full>
<PdfUploadBox>
<PdfUploadLabel>
<Icon name="file-pdf" size={16} />
PDF로
</PdfUploadLabel>
<PdfUploadRow>
<PdfFileBtn type="button" onClick={() => pdfInputRef.current?.click()}>
{pdfFile ? pdfFile.name : '문제 PDF 선택'}
</PdfFileBtn>
<input ref={pdfInputRef} type="file" accept="application/pdf" style={{ display: 'none' }}
onChange={(e) => setPdfFile(e.target.files?.[0] ?? null)} />
<PdfFileBtn type="button" $optional onClick={() => answerPdfInputRef.current?.click()}>
{answerPdfFile ? answerPdfFile.name : '해설지 (선택)'}
</PdfFileBtn>
<input ref={answerPdfInputRef} type="file" accept="application/pdf" style={{ display: 'none' }}
onChange={(e) => setAnswerPdfFile(e.target.files?.[0] ?? null)} />
<Button type="button" disabled={!pdfFile || uploadingPdf} onClick={() => void handlePdfUpload()}>
{uploadingPdf ? '처리 중...' : '문제집 생성'}
</Button>
</PdfUploadRow>
</PdfUploadBox>
</Field>
<Field>
<Label htmlFor="assignment-due-date"></Label>
<ThemedDatePicker
@@ -255,6 +342,48 @@ const ThemedDatePicker = styled(DatePicker)`
width: 100%;
`;
const PdfUploadBox = styled.div`
padding: 14px;
border-radius: 14px;
border: 1px dashed ${theme.color.borderSoftAlpha};
background: rgba(255,255,255,0.02);
display: flex;
flex-direction: column;
gap: 10px;
`;
const PdfUploadLabel = styled.div`
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: ${theme.color.textSub};
`;
const PdfUploadRow = styled.div`
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
`;
const PdfFileBtn = styled.button<{ $optional?: boolean }>`
padding: 8px 14px;
border-radius: 10px;
border: 1px solid ${({ $optional }) => $optional ? 'rgba(255,255,255,0.08)' : theme.color.borderSoftAlpha};
background: rgba(255,255,255,0.03);
color: ${theme.color.textSub};
font-size: 13px;
cursor: pointer;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: all 0.15s;
&:hover { background: rgba(255,255,255,0.06); color: ${theme.color.textBright}; }
`;
const ActionRow = styled.div`
display: flex;
justify-content: flex-end;