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:
81
ALGORITHM.md
81
ALGORITHM.md
@@ -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 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 망각곡선 상수 │
|
||||
├─────────────────────────────────────────────┤
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user