feat(phase10A): KICE 비수학 제거 + 수학 단원 분리
- schema: MathUnit enum (common/prob_stat/calculus/geometry) - User: focusUnits(Json), bojHandle(VarChar32) - ProblemSet: mathUnit optional + unique(year,examType,subjectName,mathUnit) - migration: phase10_math_units_and_ps - scripts: wipe-non-math (dry/apply), kice-import refactor (math only, 30문항을 공통22 + 확통/미적/기하 8씩으로 분할 → 2년×4 = 8 ProblemSet) - parsing strategy/ocr-fix/seed/me/study-logs: 비수학 참조 전면 제거 - data/kice, backend/uploads/problems: 비수학 폴더 삭제 - Plans.md: Phase 10 계획 확정 Phase 10A DoD 통과 — prisma validate/format OK, nest build OK, kice-import dry-run 2년 각 46문항 확인, wipe dry-run 8 set/260 problems 확인.
This commit is contained in:
80
Plans.md
80
Plans.md
@@ -156,11 +156,85 @@
|
||||
| 9.3 | 과제 CRUD + 제출 현황 UI | 과제 생성→풀기→현황 전 플로우 | 9.2 | cc:완료 [8ebd419] |
|
||||
| 9.4 | 학생 진도 + 리포트 (3 차트) | 학생 상세, 학급 리포트 렌더 | 9.3 | cc:완료 [f9c8b9a] |
|
||||
|
||||
### Phase 10 — KICE 비수학 제거 + Solved.ac PS 통합 + 온보딩 재설계
|
||||
|
||||
**목적**: 저작권 리스크(비수학 KICE 기출)를 제거하고, 수능 수학 4단원(공통/확통/미적/기하) + Solved.ac 기반 PS 두 가지 학습 트랙만 남긴 구조로 재편한다. 사용자는 온보딩에서 `수학 수능 / PS / 자격증 / 어학 / 취미` 중 목표를 선택하고, `수학 수능` 선택 시 세부 단원을, `PS` 선택 시 solved.ac 핸들을 연결한다. Solved.ac 풀이 이력은 자동 sync 되어 `StudyLog` 로 복습 큐에 진입한다.
|
||||
|
||||
### 기술 결정 (Phase 10)
|
||||
|
||||
| # | 결정 포인트 | 확정 내용 |
|
||||
|---|---|---|
|
||||
| P10-D1 | 수학 단원 모델 | `enum MathUnit { common, prob_stat, calculus, geometry }` — `common` = 수학Ⅰ+Ⅱ, 선택 3종(확률과 통계/미적분/기하). `ProblemSet.mathUnit MathUnit?` 으로 nullable 추가 |
|
||||
| P10-D2 | KICE 30문항 단원 분할 | 공통 22문항 (1~22) + 선택 8문항 × 3단원 (23~30 각 세트). 즉 year 당 수학 ProblemSet = 4개(common/prob_stat/calculus/geometry), problems = 22+8+8+8 = 46. 2년치 × 4 = **8 ProblemSet** 총 92 problems |
|
||||
| P10-D3 | 비수학 데이터 처리 | Dev DB 에서 `ProblemSet.subjectName NOT IN ('수학')` 에 연결된 Problem/StudyLog/ReviewSchedule/AssignmentSubmission cascade delete. 되돌리기 불가 — 마이그레이션 전 mysqldump 백업 필수 |
|
||||
| P10-D4 | Solved.ac 통합 깊이 | **(c) 풀: 검색+북마크 + 핸들 자동 sync**. `SolvedAcClient` 는 `https://solved.ac/api/v3` 공개 API 사용(인증 불필요), `user/show` + `user/problem_stats` + `user/top_100` 조합으로 bulk import |
|
||||
| P10-D5 | PS → StudyLog 매핑 | `PsProblem` 별도 테이블에 캐시. `StudyLog.problemId` 는 KICE `Problem` FK 라 재사용 불가 → `StudyLog.psProblemId Int?` 추가. `subject` 는 자동 생성되는 시스템 Subject `"PS"` 로 묶음 |
|
||||
| P10-D6 | 온보딩 GoalType | `'math-suneung' \| 'ps' \| 'certificate' \| 'language' \| 'none'`. `User.focusUnits Json?` 추가(수학 단원 멀티셀렉트 저장), `User.bojHandle String?` 추가 |
|
||||
| P10-D7 | 기존 focusSubjects | `focusSubjects`는 그대로 유지하되 수학 선택지만 남김. 자격증/어학/취미 트랙은 자유 입력 유지 |
|
||||
| P10-D8 | 학교/학원 기능 | Phase 9 Assignment 는 수학 ProblemSet 에 대해서만 작동. 스키마 변경 없음 |
|
||||
|
||||
### Phase 10A — 스키마 + KICE 비수학 제거
|
||||
|
||||
| Task | 내용 | DoD | Depends | Status |
|
||||
|------|------|-----|---------|--------|
|
||||
| 7E.1 | Backend build + mailu-dev prisma migrate deploy + reloop-api reload | dist/main.js 실행, `/api/me/problem-sets` 3 엔드포인트 200 응답, 2026 수능 5 ProblemSet 노출 | 7A.6, 7B.2 | cc:TODO |
|
||||
| 7E.2 | Frontend build + standalone copy + reloop-web reload | `/`, `/login`, `/pricing`, `/dashboard`, `/review`, `/study`, `/study/history`, `/stats`, `/subjects`, `/profile` 10 경로 전부 200 + 신규 디자인 렌더 | 7D.12 | cc:TODO |
|
||||
| 7E.3 | harness-review code (`--base` = Phase 7 시작 지점) — 30+ commit 대상 감사 | critical/major 0 또는 수정 루프 통과 | 7E.1, 7E.2 | cc:TODO |
|
||||
| 10A.1 | Prisma 스키마: `MathUnit` enum, `ProblemSet.mathUnit MathUnit?`, `User.bojHandle String? @db.VarChar(32)`, `User.focusUnits Json?`. migration `phase10_math_units_and_ps` 생성 | `prisma migrate dev --name phase10_math_units_and_ps` 성공, `@prisma/client` 재생성, 기존 행 NULL 기본값 | - | cc:TODO |
|
||||
| 10A.2 | 비수학 데이터 wipe 스크립트 `backend/scripts/wipe-non-math.ts` — `ProblemSet where subjectName != '수학'` 연쇄 삭제(Assignment/AssignmentSubmission, StudyLog, ReviewSchedule, Problem, Passage 포함). 실행 전 mysqldump 백업을 `backups/` 에 저장 | dry-run 시 삭제 대상 수 출력, `--apply` 시 실제 삭제 후 `ProblemSet count where subjectName='수학'` 만 남음 | 10A.1 | cc:TODO |
|
||||
| 10A.3 | `kice-import.ts` 리팩터: `TARGET_SUBJECTS = ['수학']`, 30문항을 1~22 = common, 23~30 = 선택 × 3 단원으로 split 하여 4 ProblemSet 생성. `ProblemSet.title` 은 `YYYY학년도 수능 수학 (공통)` 형태 | `pnpm cli:kice-import --dry-run --year=2026` 시 common 22 + 확통 8 + 미적 8 + 기하 8 = 46 문항 리포트 | 10A.2 | cc:TODO |
|
||||
| 10A.4 | `backend/src/problem-sets/parsing/strategies/kice.ts` 및 관련 OCR 프롬프트에서 비수학 분기 제거. `shared.ts`·`ocr-fix.ts`·`seed.ts` 에서 비수학 과목 참조 삭제 | `grep -rE "국어\|영어\|한국사\|생활과 윤리\|사회탐구" backend/src backend/scripts` 결과 0 | 10A.3 | cc:TODO |
|
||||
| 10A.5 | `data/kice/{2025,2026}/{국어,영어,한국사,사회탐구}/` 삭제 + `.gitignore` 정리. `backend/uploads/problems/{year}/{비수학 과목}/` 도 삭제 | 디스크에 수학 PDF + 수학 이미지 crop 만 남음 | 10A.4 | cc:TODO |
|
||||
|
||||
### Phase 10B — Solved.ac PS 모듈
|
||||
|
||||
| Task | 내용 | DoD | Depends | Status |
|
||||
|------|------|-----|---------|--------|
|
||||
| 10B.1 | Prisma: `model PsProblem { id Int, bojId Int @unique, title, level Int, tags Json, acceptedUserCount Int?, averageTries Float?, updatedAt }`, `model PsBookmark { userId, psProblemId, createdAt @@unique([userId,psProblemId]) }`, `StudyLog.psProblemId Int?` FK 추가. migration `phase10_ps_tables` | `prisma migrate dev` 성공, 관계 매핑 검증 | 10A.1 | cc:TODO |
|
||||
| 10B.2 | `backend/src/ps/solved-ac.client.ts` — axios 인스턴스(`https://solved.ac/api/v3`, User-Agent 명시, 타임아웃 10s, 지수 백오프 3회). 메서드: `searchProblems(query, page)`, `getProblem(bojId)`, `getUser(handle)`, `getUserSolvedProblems(handle, page)`. 내부 rate-limit (초당 3req) | 단위 테스트: mocked axios 로 4 메서드 응답 파싱 정상 | 10B.1 | cc:TODO |
|
||||
| 10B.3 | `PsModule` + `PsService` + `PsController` — endpoints: `GET /api/ps/search?q=&level=&page=`, `GET /api/ps/problems/:bojId`, `POST /api/ps/bookmarks { bojId }`, `DELETE /api/ps/bookmarks/:bojId`, `GET /api/ps/bookmarks`, `POST /api/ps/sync { bojHandle? }` (핸들 없으면 `User.bojHandle` 사용). JWT guard + throttler | Postman/curl 6 endpoint 200, sync 실행 시 PsProblem upsert + 시스템 Subject "PS" 생성 + StudyLog bulk insert, 멱등성 확인(중복 sync 시 같은 BOJ 문제 StudyLog 추가 생성 안 함) | 10B.2 | cc:TODO |
|
||||
| 10B.4 | Sync 멱등성 저장소: `PsSyncState { userId @unique, lastSyncedAt, lastSolvedCount }` 추가해 마지막 sync 이후 새로 맞힌 문제만 가져옴. 초기 sync 는 `top_100` 기준 상위 100개로 제한(과도한 StudyLog 폭주 방지) | 1차 sync 후 `PsSyncState.lastSolvedCount` 저장, 2차 sync 는 차분만 insert | 10B.3 | cc:TODO |
|
||||
|
||||
### Phase 10C — 온보딩/프론트 재구성
|
||||
|
||||
| Task | 내용 | DoD | Depends | Status |
|
||||
|------|------|-----|---------|--------|
|
||||
| 10C.1 | `frontend/src/app/onboarding/page.tsx` — GoalType 옵션 라벨/설명 교체. `math-suneung` 선택 시 4 단원 체크박스 그룹 노출, `ps` 선택 시 `bojHandle` 인풋(정규식 검증 `^[a-zA-Z0-9_-]{3,20}$`) 노출 | 각 GoalType 분기 UI 정상, 저장 후 `/auth/me` 응답에 `focusUnits` 또는 `bojHandle` 반영 | 10B.1 | cc:TODO |
|
||||
| 10C.2 | `frontend/src/lib/api.ts` + `MeUser` 타입: `bojHandle`, `focusUnits: MathUnit[]`, `GoalType` 유니언 갱신. `api/me` PATCH payload 확장 | 타입 에러 0, API PATCH 200 | 10C.1 | cc:TODO |
|
||||
| 10C.3 | 전역 과목 리스트 정리 — `subjects/page.tsx`, `exams/page.tsx`, `review/page.tsx`, `review/history/page.tsx`, `dashboard`, `school/*` 에서 비수학 하드코딩 참조 제거. `exam/shared.ts::getExamDurationSeconds` → 수학 100분 고정. `SideNav` 에서 국어/영어/한국사 링크 제거 | `grep -rE "국어\|영어\|한국사\|생활과 윤리" frontend/src` = 0 | 10C.2 | cc:TODO |
|
||||
| 10C.4 | `frontend/src/app/ps/` 신규 섹션 — `page.tsx`(검색 + 필터 + 북마크 버튼), `bookmarks/page.tsx`(북마크 목록), `sync/page.tsx`(핸들 동기화 버튼 + 상태). BottomNav/SideNav 에 `PS` 진입점 추가 | 4 경로 200 + search/bookmark/sync e2e 수동 검증 통과 | 10C.2 | cc:TODO |
|
||||
| 10C.5 | `landing`/`pricing`/`dashboard` 카피 업데이트 — "수능 국어/영어…" 문구를 "수학 수능 + PS" 로 교체, 아카이브 design HTML 과 docs 에서도 혼동 없도록 정리 | `grep -rE "국어\|영어\|한국사\|생활과 윤리" frontend/src docs README.md` = 0 (아카이브 `.claude/archive/` 는 제외) | 10C.3 | cc:TODO |
|
||||
|
||||
### Phase 10D — Dev 배포 + 검증
|
||||
|
||||
| Task | 내용 | DoD | Depends | Status |
|
||||
|------|------|-----|---------|--------|
|
||||
| 10D.1 | 로컬 커밋 + push → Gitea `hanarang/reloop-v2 master`. commit 단위는 10A / 10B / 10C 별로 나눔 | `git push` 성공, `tea pr list` 없이 master 직접 반영 | 10C.5 | cc:TODO |
|
||||
| 10D.2 | `ssh dev@10.10.10.169` → `cd ~/reloop-v2 && git pull`. backend 에서 `mysqldump reloop_v2 > backups/phase10-pre.sql` 실행 후 `pnpm --filter backend exec prisma migrate deploy` → `pnpm --filter backend build` | 마이그레이션 적용됨, dist/main.js 빌드 성공, DB 백업 존재 | 10D.1 | cc:TODO |
|
||||
| 10D.3 | 비수학 wipe 스크립트 운영 실행 — `node backend/dist/scripts/wipe-non-math.js --apply`. 이어서 `pnpm cli:kice-import --year=2025 && --year=2026` 로 수학 8 ProblemSet 재생성 | DB 에 수학 8 ProblemSet / 92 problems, 비수학 0 | 10D.2 | cc:TODO |
|
||||
| 10D.4 | Frontend 빌드 + standalone copy + `pm2 reload reloop-web reloop-api --update-env` | PM2 status online, `curl https://reloop-api.nabomhalang.co.kr/health` 200, `curl https://reloop.nabomhalang.co.kr` 200 | 10D.3 | cc:TODO |
|
||||
| 10D.5 | 스모크 테스트 — 온보딩 `math-suneung` 플로우 + 단원 선택 저장, `ps` 플로우 + 더미 BOJ 핸들 sync, `/ps/search` 검색, `/exams` 에 수학 4단원 표시, `/review` 에 비수학 흔적 없음 | 5 시나리오 수동 통과 스크린샷 또는 로그 캡처 | 10D.4 | cc:TODO |
|
||||
|
||||
### Phase 10 범위 / 비 범위
|
||||
|
||||
**포함**:
|
||||
- KICE 비수학 과목(국어/영어/한국사/생활과 윤리) 코드·데이터·DB 전량 제거
|
||||
- 수학 4단원 분할(common/prob_stat/calculus/geometry) + 재import
|
||||
- Solved.ac 기반 PS 모듈 (검색/북마크/핸들 자동 sync → StudyLog)
|
||||
- 온보딩 재설계 (`math-suneung` 세부 단원 / `ps` bojHandle)
|
||||
- Dev 배포 + DB 백업/마이그레이션/스모크
|
||||
|
||||
**제외** (별도 트랙):
|
||||
- Solved.ac 인증 API(로그인 연동) — 공개 API 로 충분
|
||||
- PS 문제 로컬 렌더링 — BOJ 링크 아웃바운드 유지
|
||||
- 수학 30문항 → 단원 분할 비율 조정(정밀 매핑표 필요 시 별도 Phase)
|
||||
- 기존 사용자 데이터 마이그레이션 보전(비수학 StudyLog 는 cascade delete)
|
||||
- i18n / 영어 UI / PWA
|
||||
- 이 Phase 에서는 새 UI 디자인 토큰 변경 없음
|
||||
|
||||
### 실행 모드
|
||||
|
||||
- 전량 `harness-work --codex` 위임, Lead 세션이 codex helper 를 통해 Task 단위로 구현/검증
|
||||
- 배포(10D)는 ssh + PM2 수동 단계 포함 → Lead 세션이 Bash 로 직접 실행
|
||||
- DB 파괴적 작업(10A.2, 10D.3) 실행 전 mysqldump 백업 필수
|
||||
|
||||
|
||||
### 범위 / 비 범위
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"prisma:deploy": "prisma migrate deploy",
|
||||
"prisma:seed": "ts-node prisma/seed.ts",
|
||||
"cli:kice-import": "ts-node scripts/kice-import.ts",
|
||||
"cli:ocr-fix": "ts-node scripts/ocr-fix.ts"
|
||||
"cli:ocr-fix": "ts-node scripts/ocr-fix.ts",
|
||||
"cli:wipe-non-math": "ts-node scripts/wipe-non-math.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[year,examType,subjectName,mathUnit]` on the table `problem_sets` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX `problem_sets_year_examType_subjectName_key` ON `problem_sets`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `problem_sets` ADD COLUMN `mathUnit` ENUM('common', 'prob_stat', 'calculus', 'geometry') NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `users` ADD COLUMN `bojHandle` VARCHAR(32) NULL,
|
||||
ADD COLUMN `focusUnits` JSON NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX `problem_sets_year_examType_subjectName_mathUnit_key` ON `problem_sets`(`year`, `examType`, `subjectName`, `mathUnit`);
|
||||
@@ -43,6 +43,13 @@ enum ReviewStatus {
|
||||
expired
|
||||
}
|
||||
|
||||
enum MathUnit {
|
||||
common
|
||||
prob_stat
|
||||
calculus
|
||||
geometry
|
||||
}
|
||||
|
||||
enum SubscriptionTier {
|
||||
free
|
||||
pro
|
||||
@@ -79,6 +86,8 @@ model User {
|
||||
targetGrade Int?
|
||||
targetExamYear Int?
|
||||
focusSubjects Json?
|
||||
focusUnits Json?
|
||||
bojHandle String? @db.VarChar(32)
|
||||
reviewIntensity ReviewIntensity @default(moderate)
|
||||
onboardedAt DateTime?
|
||||
subscriptionTier SubscriptionTier @default(free)
|
||||
@@ -183,23 +192,24 @@ model ReviewSchedule {
|
||||
/// 평가원/수능/모의평가 등 공개 기출 문제를 담는 컨테이너.
|
||||
/// Subject 와 별개로 존재 — 모든 사용자가 공유하며 읽기 전용.
|
||||
model ProblemSet {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
/// examType 허용값: "sat" | "mock-june" | "mock-sept" | "academy"
|
||||
examType String
|
||||
year Int
|
||||
subjectName String
|
||||
mathUnit MathUnit?
|
||||
sourceUrl String?
|
||||
/// 영어 과목의 듣기 음원 파일 경로 배열 (data/kice/ 상대경로).
|
||||
/// 예: ["2026/영어/영어영역듣기평가음원/01_문제 01.mp3", ...]
|
||||
/// 듣기 음원 파일 경로 배열 (data/kice/ 상대경로).
|
||||
/// 예: ["2026/listening/audios/01_track.mp3", ...]
|
||||
audioUrls Json?
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
problems Problem[]
|
||||
passages Passage[]
|
||||
assignments Assignment[]
|
||||
|
||||
@@unique([year, examType, subjectName])
|
||||
@@unique([year, examType, subjectName, mathUnit])
|
||||
@@map("problem_sets")
|
||||
}
|
||||
|
||||
@@ -317,7 +327,7 @@ model Problem {
|
||||
pageImageUrl String?
|
||||
/// KICE 파싱 결과 — 정답 번호 1..5
|
||||
answerNumber Int?
|
||||
/// 공통 지문 FK (국어 독서/문학, 영어 지문, 사탐 자료 등)
|
||||
/// 공통 지문 FK (여러 문제가 공유하는 지문)
|
||||
passageId Int?
|
||||
passage Passage? @relation(fields: [passageId], references: [id], onDelete: SetNull)
|
||||
/// 파싱 엣지케이스 플래그 — 수동 보정 대상
|
||||
|
||||
@@ -26,11 +26,6 @@ interface SubjectSeed {
|
||||
}
|
||||
|
||||
const SUBJECTS: SubjectSeed[] = [
|
||||
{
|
||||
name: "국어",
|
||||
color: "#ef4444",
|
||||
tags: ["문학", "독서(비문학)", "화법과작문", "언어와매체", "고전시가"],
|
||||
},
|
||||
{
|
||||
name: "수학",
|
||||
color: "#3b82f6",
|
||||
@@ -44,29 +39,6 @@ const SUBJECTS: SubjectSeed[] = [
|
||||
"수2 적분",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "영어",
|
||||
color: "#22c55e",
|
||||
tags: [
|
||||
"문법/어법",
|
||||
"어휘",
|
||||
"빈칸추론",
|
||||
"순서배열",
|
||||
"삽입",
|
||||
"주제/제목",
|
||||
"함축의미",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "사회탐구",
|
||||
color: "#f59e0b",
|
||||
tags: ["생활과윤리", "사회문화", "한국지리", "세계사"],
|
||||
},
|
||||
{
|
||||
name: "과학탐구",
|
||||
color: "#8b5cf6",
|
||||
tags: ["물리1", "화학1", "생명과학1", "지구과학1"],
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import { PrismaClient, Prisma, MathUnit } from '@prisma/client';
|
||||
import { parseImageBasedExam, ParseResult } from '../src/problem-sets/parsing';
|
||||
|
||||
interface CliOptions {
|
||||
@@ -33,17 +33,27 @@ function parseArgs(): CliOptions {
|
||||
const DATA_DIR = path.resolve(__dirname, '../../data/kice');
|
||||
const PROBLEM_UPLOAD_DIR = path.resolve(__dirname, '../uploads/problems');
|
||||
const TARGET_YEARS = [2025, 2026];
|
||||
const TARGET_SUBJECTS = ['국어', '영어', '한국사', '생활과 윤리', '수학'] as const;
|
||||
const TARGET_SUBJECTS = ['수학'] as const;
|
||||
type TargetSubject = (typeof TARGET_SUBJECTS)[number];
|
||||
const SOURCE_URL = 'https://www.suneung.re.kr/';
|
||||
|
||||
const EXPECTED_PROBLEM_COUNT: Record<TargetSubject, number> = {
|
||||
국어: 45,
|
||||
영어: 45,
|
||||
한국사: 20,
|
||||
'생활과 윤리': 20,
|
||||
수학: 30,
|
||||
};
|
||||
|
||||
const MATH_UNIT_CONFIGS: Array<{
|
||||
unit: MathUnit;
|
||||
displayName: string;
|
||||
range: { start: number; end: number };
|
||||
}> = [
|
||||
{ unit: MathUnit.common, displayName: '공통', range: { start: 1, end: 22 } },
|
||||
{ unit: MathUnit.prob_stat, displayName: '확률과 통계', range: { start: 23, end: 30 } },
|
||||
{ unit: MathUnit.calculus, displayName: '미적분', range: { start: 23, end: 30 } },
|
||||
{ unit: MathUnit.geometry, displayName: '기하', range: { start: 23, end: 30 } },
|
||||
];
|
||||
|
||||
const ELECTIVE_PROBLEM_RANGE = { start: 23, end: 30 };
|
||||
|
||||
interface PdfFile {
|
||||
year: number;
|
||||
subject: TargetSubject;
|
||||
@@ -54,12 +64,31 @@ interface PdfFile {
|
||||
|
||||
interface ScanResult {
|
||||
files: PdfFile[];
|
||||
audioMp3s: Map<string, string[]>;
|
||||
}
|
||||
|
||||
interface MathExamBundle {
|
||||
parsed: ParseResult;
|
||||
answerMap: Map<number, number>;
|
||||
splits: MathUnitSplit[];
|
||||
}
|
||||
|
||||
interface MathUnitSplit {
|
||||
unit: MathUnit;
|
||||
title: string;
|
||||
problems: ParseResult['problems'];
|
||||
passages: ParseResult['passages'];
|
||||
usesFallback: boolean;
|
||||
}
|
||||
|
||||
interface MathUnitSummary {
|
||||
unit: MathUnit;
|
||||
problems: number;
|
||||
passages: number;
|
||||
needsReview: number;
|
||||
}
|
||||
|
||||
function scanKiceData(filter: { year?: number; subject?: string }): ScanResult {
|
||||
const files: PdfFile[] = [];
|
||||
const audioMp3s = new Map<string, string[]>();
|
||||
|
||||
for (const year of TARGET_YEARS) {
|
||||
if (filter.year && filter.year !== year) continue;
|
||||
@@ -67,21 +96,6 @@ function scanKiceData(filter: { year?: number; subject?: string }): ScanResult {
|
||||
for (const subject of TARGET_SUBJECTS) {
|
||||
if (filter.subject && filter.subject !== subject) continue;
|
||||
|
||||
if (subject === '생활과 윤리') {
|
||||
const problemPath = path.join(DATA_DIR, String(year), '사회탐구/사회탐구영역_문제지');
|
||||
const answerPath = path.join(DATA_DIR, String(year), '사회탐구/사회탐구영역_정답표');
|
||||
const problemFile = findFile(problemPath, /01 생활과 윤리_(문제|문제지)\.pdf$/);
|
||||
const answerFile = findFile(answerPath, /01 생활과 윤리_(정답|정답표)\.pdf$/);
|
||||
|
||||
if (problemFile) {
|
||||
files.push(buildPdfFile(year, subject, 'problems', problemFile));
|
||||
}
|
||||
if (answerFile) {
|
||||
files.push(buildPdfFile(year, subject, 'answer', answerFile));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const subjectDir = path.join(DATA_DIR, String(year), subject);
|
||||
if (!fs.existsSync(subjectDir)) continue;
|
||||
|
||||
@@ -91,7 +105,6 @@ function scanKiceData(filter: { year?: number; subject?: string }): ScanResult {
|
||||
findFile(subjectDir, /_문제지_짝수형\.pdf$/) ||
|
||||
findFile(subjectDir, /_문제\.pdf$/);
|
||||
const answerFile = findFile(subjectDir, /_정답표?\.pdf$/);
|
||||
const scriptFile = findFile(subjectDir, /_듣기평가대본\.pdf$/);
|
||||
|
||||
if (problemFile) {
|
||||
files.push(buildPdfFile(year, subject, 'problems', problemFile));
|
||||
@@ -99,34 +112,10 @@ function scanKiceData(filter: { year?: number; subject?: string }): ScanResult {
|
||||
if (answerFile) {
|
||||
files.push(buildPdfFile(year, subject, 'answer', answerFile));
|
||||
}
|
||||
if (scriptFile) {
|
||||
files.push(buildPdfFile(year, subject, 'audio-script', scriptFile));
|
||||
}
|
||||
|
||||
if (subject === '영어') {
|
||||
const audioDir1 = path.join(subjectDir, '영어영역_듣기평가음원');
|
||||
const audioDir2 = path.join(subjectDir, '영어영역듣기평가음원');
|
||||
const audioDir = fs.existsSync(audioDir1)
|
||||
? audioDir1
|
||||
: fs.existsSync(audioDir2)
|
||||
? audioDir2
|
||||
: null;
|
||||
|
||||
if (audioDir) {
|
||||
const mp3s = fs
|
||||
.readdirSync(audioDir)
|
||||
.filter((entry) => entry.endsWith('.mp3'))
|
||||
.sort()
|
||||
.map((entry) => path.relative(DATA_DIR, path.join(audioDir, entry)));
|
||||
if (mp3s.length > 0) {
|
||||
audioMp3s.set(`${year}/영어`, mp3s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { files, audioMp3s };
|
||||
return { files };
|
||||
}
|
||||
|
||||
function buildPdfFile(
|
||||
@@ -150,63 +139,135 @@ function findFile(dir: string, pattern: RegExp): string | null {
|
||||
return match ? path.join(dir, match) : null;
|
||||
}
|
||||
|
||||
function buildProblemSetTitle(year: number, subject: TargetSubject): string {
|
||||
return `${year}학년도 대학수학능력시험 ${subject}`;
|
||||
function buildMathProblemSetTitle(year: number, displayName: string): string {
|
||||
return `${year}학년도 수능 수학 (${displayName})`;
|
||||
}
|
||||
|
||||
async function importKiceSet(
|
||||
prisma: PrismaClient,
|
||||
async function parseMathExamBundle(
|
||||
year: number,
|
||||
subject: TargetSubject,
|
||||
files: PdfFile[],
|
||||
audioMp3s: Map<string, string[]>,
|
||||
): Promise<{ problems: number; passages: number; needsReview: number; warnings: string[] }> {
|
||||
): Promise<MathExamBundle | null> {
|
||||
const answerFile = files.find((file) => file.role === 'answer');
|
||||
const problemFile = files.find((file) => file.role === 'problems');
|
||||
|
||||
if (!problemFile) {
|
||||
console.warn(`⚠ [${year} ${subject}] 문제지 PDF 누락`);
|
||||
return { problems: 0, passages: 0, needsReview: 0, warnings: ['missing problem PDF'] };
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = await parseSubjectFiles(year, subject, problemFile.filepath, answerFile?.filepath);
|
||||
|
||||
const answerMap = new Map(parsed.answers.map((answer) => [answer.number, answer.answerNumber]));
|
||||
if (answerFile && parsed.answers.length === 0) {
|
||||
console.warn(`⚠ [${year} ${subject}] 정답표 추출 실패 (이미지 기반 PDF?) — answerNumber 전부 null`);
|
||||
const splits = splitMathProblemSets(year, parsed);
|
||||
|
||||
return { parsed, answerMap, splits };
|
||||
}
|
||||
|
||||
function splitMathProblemSets(year: number, parsed: ParseResult): MathUnitSplit[] {
|
||||
return MATH_UNIT_CONFIGS.map((config) => {
|
||||
let problems = filterProblemsByRange(parsed.problems, config.range.start, config.range.end);
|
||||
let passages = filterPassagesByRange(parsed.passages, config.range.start, config.range.end);
|
||||
let usesFallback = false;
|
||||
|
||||
if (config.unit !== MathUnit.common && problems.length === 0) {
|
||||
problems = filterProblemsByRange(
|
||||
parsed.problems,
|
||||
ELECTIVE_PROBLEM_RANGE.start,
|
||||
ELECTIVE_PROBLEM_RANGE.end,
|
||||
);
|
||||
passages = filterPassagesByRange(
|
||||
parsed.passages,
|
||||
ELECTIVE_PROBLEM_RANGE.start,
|
||||
ELECTIVE_PROBLEM_RANGE.end,
|
||||
);
|
||||
usesFallback = true;
|
||||
// TODO: 선택과목 PDF 가 분리되면 파일명 기반으로 단원을 정확히 매핑하도록 개선한다.
|
||||
}
|
||||
|
||||
return {
|
||||
unit: config.unit,
|
||||
title: buildMathProblemSetTitle(year, config.displayName),
|
||||
problems,
|
||||
passages,
|
||||
usesFallback,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function filterProblemsByRange(
|
||||
problems: ParseResult['problems'],
|
||||
start: number,
|
||||
end: number,
|
||||
): ParseResult['problems'] {
|
||||
return problems.filter((problem) => problem.number >= start && problem.number <= end);
|
||||
}
|
||||
|
||||
function filterPassagesByRange(
|
||||
passages: ParseResult['passages'],
|
||||
start: number,
|
||||
end: number,
|
||||
): ParseResult['passages'] {
|
||||
return passages.filter((passage) => passage.endNumber >= start && passage.startNumber <= end);
|
||||
}
|
||||
|
||||
async function importKiceMathSet(
|
||||
prisma: PrismaClient,
|
||||
year: number,
|
||||
subject: TargetSubject,
|
||||
files: PdfFile[],
|
||||
): Promise<MathUnitSummary[] | null> {
|
||||
const bundle = await parseMathExamBundle(year, subject, files);
|
||||
if (!bundle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const warning of parsed.warnings) {
|
||||
for (const warning of bundle.parsed.warnings) {
|
||||
console.warn(` ⚠ [${year} ${subject}] ${warning}`);
|
||||
}
|
||||
|
||||
const audioUrls = subject === '영어' ? audioMp3s.get(`${year}/영어`) || null : null;
|
||||
const summaries: MathUnitSummary[] = [];
|
||||
for (const split of bundle.splits) {
|
||||
const summary = await upsertMathUnitProblemSet(prisma, year, subject, split, bundle.answerMap);
|
||||
summaries.push(summary);
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
async function upsertMathUnitProblemSet(
|
||||
prisma: PrismaClient,
|
||||
year: number,
|
||||
subject: TargetSubject,
|
||||
split: MathUnitSplit,
|
||||
answerMap: Map<number, number>,
|
||||
): Promise<MathUnitSummary> {
|
||||
const problemSet = await prisma.problemSet.upsert({
|
||||
where: {
|
||||
year_examType_subjectName: {
|
||||
year_examType_subjectName_mathUnit: {
|
||||
year,
|
||||
examType: 'sat',
|
||||
subjectName: subject,
|
||||
mathUnit: split.unit,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
title: buildProblemSetTitle(year, subject),
|
||||
sourceUrl: 'https://www.suneung.re.kr/',
|
||||
audioUrls: audioUrls as Prisma.InputJsonValue | null | undefined,
|
||||
title: split.title,
|
||||
sourceUrl: SOURCE_URL,
|
||||
mathUnit: split.unit,
|
||||
},
|
||||
create: {
|
||||
title: buildProblemSetTitle(year, subject),
|
||||
title: split.title,
|
||||
examType: 'sat',
|
||||
year,
|
||||
subjectName: subject,
|
||||
sourceUrl: 'https://www.suneung.re.kr/',
|
||||
audioUrls: audioUrls as Prisma.InputJsonValue | null | undefined,
|
||||
mathUnit: split.unit,
|
||||
sourceUrl: SOURCE_URL,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.passage.deleteMany({ where: { problemSetId: problemSet.id } });
|
||||
const createdPassages = await Promise.all(
|
||||
parsed.passages.map((passage) =>
|
||||
split.passages.map((passage) =>
|
||||
prisma.passage.create({
|
||||
data: {
|
||||
problemSetId: problemSet.id,
|
||||
@@ -221,14 +282,12 @@ async function importKiceSet(
|
||||
const passageIdByStart = new Map(createdPassages.map((passage) => [passage.startNumber, passage.id]));
|
||||
|
||||
let needsReviewCount = 0;
|
||||
for (const problem of parsed.problems) {
|
||||
const passageId = problem.passageStart
|
||||
? passageIdByStart.get(problem.passageStart) || null
|
||||
: null;
|
||||
for (const problem of split.problems) {
|
||||
const passageId = problem.passageStart ? passageIdByStart.get(problem.passageStart) || null : null;
|
||||
const answerNumber = answerMap.get(problem.number) || null;
|
||||
const needsReview = problem.needsReview || !answerNumber;
|
||||
if (needsReview) {
|
||||
needsReviewCount++;
|
||||
needsReviewCount += 1;
|
||||
}
|
||||
|
||||
await prisma.problem.upsert({
|
||||
@@ -247,7 +306,7 @@ async function importKiceSet(
|
||||
create: {
|
||||
problemSetId: problemSet.id,
|
||||
number: problem.number,
|
||||
title: `${buildProblemSetTitle(year, subject)} ${problem.number}번`,
|
||||
title: `${split.title} ${problem.number}번`,
|
||||
difficulty: 0.5,
|
||||
bodyText: problem.bodyText,
|
||||
choices: problem.choices as Prisma.InputJsonValue,
|
||||
@@ -267,10 +326,10 @@ async function importKiceSet(
|
||||
}
|
||||
|
||||
return {
|
||||
problems: parsed.problems.length,
|
||||
passages: parsed.passages.length,
|
||||
unit: split.unit,
|
||||
problems: split.problems.length,
|
||||
passages: split.passages.length,
|
||||
needsReview: needsReviewCount,
|
||||
warnings: parsed.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -281,16 +340,10 @@ async function inspectParseResult(
|
||||
mode: 'answer' | 'problems',
|
||||
sample?: number,
|
||||
) {
|
||||
const answerFile = files.find((file) => file.role === 'answer');
|
||||
const problemFile = files.find((file) => file.role === 'problems');
|
||||
|
||||
if (!problemFile) {
|
||||
console.warn(`⚠ [${year} ${subject}] 문제지 PDF 누락`);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = await parseSubjectFiles(year, subject, problemFile.filepath, answerFile?.filepath);
|
||||
const bundle = await parseMathExamBundle(year, subject, files);
|
||||
if (!bundle) return;
|
||||
|
||||
const parsed = bundle.parsed;
|
||||
if (mode === 'answer') {
|
||||
console.log(` ${year} / ${subject}: ${parsed.answers.length} 문항`);
|
||||
for (const answer of parsed.answers.slice(0, sample ? 5 : 0)) {
|
||||
@@ -329,28 +382,58 @@ async function parseSubjectFiles(
|
||||
return parseImageBasedExam({
|
||||
paperPdfPath,
|
||||
answerPdfPath,
|
||||
format: subject === '수학' ? 'kice-math' : 'kice',
|
||||
format: 'kice-math',
|
||||
expectedProblemCount,
|
||||
renderedImageDir,
|
||||
renderedImageBaseUrl: `/uploads/problems/${year}/${subject}`,
|
||||
});
|
||||
}
|
||||
|
||||
function formatMathSummaryLine(year: number, summaries: MathUnitSummary[]): string {
|
||||
const parts = summaries.map((summary) => `${summary.unit} ${summary.problems}`);
|
||||
const total = summaries.reduce((sum, summary) => sum + summary.problems, 0);
|
||||
return `year ${year}: ${parts.join(' / ')} = ${total} 문항`;
|
||||
}
|
||||
|
||||
async function runDryRun(scan: ScanResult, options: CliOptions) {
|
||||
console.log('\n=== Dry-run (math unit split) ===');
|
||||
let printed = false;
|
||||
|
||||
for (const year of TARGET_YEARS) {
|
||||
if (options.year && options.year !== year) continue;
|
||||
for (const subject of TARGET_SUBJECTS) {
|
||||
if (options.subject && options.subject !== subject) continue;
|
||||
const files = scan.files.filter((file) => file.year === year && file.subject === subject);
|
||||
if (files.length === 0) continue;
|
||||
|
||||
const bundle = await parseMathExamBundle(year, subject, files);
|
||||
if (!bundle) continue;
|
||||
|
||||
const summaries = bundle.splits.map((split) => ({
|
||||
unit: split.unit,
|
||||
problems: split.problems.length,
|
||||
passages: split.passages.length,
|
||||
needsReview: split.problems.filter((problem) => problem.needsReview).length,
|
||||
}));
|
||||
console.log(formatMathSummaryLine(year, summaries));
|
||||
printed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!printed) {
|
||||
console.log('No matching math problem sets found.');
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs();
|
||||
console.log('🏫 KICE import 시작', options);
|
||||
|
||||
const scan = scanKiceData({ year: options.year, subject: options.subject });
|
||||
console.log(`스캔: ${scan.files.length} files, ${scan.audioMp3s.size} audio sets`);
|
||||
console.log(`스캔: ${scan.files.length} files`);
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log('\n=== 파일 목록 (dry-run) ===');
|
||||
for (const file of scan.files) {
|
||||
console.log(` ${file.year} / ${file.subject} / ${file.role.padEnd(14)} — ${file.sizeKB}KB — ${path.basename(file.filepath)}`);
|
||||
}
|
||||
for (const [key, mp3s] of scan.audioMp3s) {
|
||||
console.log(` audio ${key}: ${mp3s.length} mp3 files`);
|
||||
}
|
||||
await runDryRun(scan, options);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -381,13 +464,15 @@ async function main() {
|
||||
const files = scan.files.filter((file) => file.year === year && file.subject === subject);
|
||||
if (files.length === 0) continue;
|
||||
|
||||
const result = await importKiceSet(prisma, year, subject, files, scan.audioMp3s);
|
||||
console.log(
|
||||
`✓ ${year} ${subject}: ${result.problems} problems, ${result.passages} passages, ${result.needsReview} needsReview`,
|
||||
);
|
||||
totalProblems += result.problems;
|
||||
totalPassages += result.passages;
|
||||
totalNeedsReview += result.needsReview;
|
||||
const summaries = await importKiceMathSet(prisma, year, subject, files);
|
||||
if (!summaries) continue;
|
||||
|
||||
console.log(`✓ ${formatMathSummaryLine(year, summaries)}`);
|
||||
for (const summary of summaries) {
|
||||
totalProblems += summary.problems;
|
||||
totalPassages += summary.passages;
|
||||
totalNeedsReview += summary.needsReview;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { execFileSync } from 'child_process';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import { ocrAnswerTable, ocrProblemPage } from '../src/problem-sets/parsing/ocr-fallback';
|
||||
|
||||
type TargetSubject = '국어' | '영어' | '한국사' | '생활과 윤리' | '수학';
|
||||
type TargetSubject = '수학';
|
||||
type ChoiceKey = '1' | '2' | '3' | '4' | '5';
|
||||
|
||||
interface CliOptions {
|
||||
@@ -69,12 +69,8 @@ interface ProblemCreate {
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const DATA_DIR = path.resolve(__dirname, '../../data/kice');
|
||||
const TARGET_SUBJECTS: TargetSubject[] = ['국어', '영어', '한국사', '생활과 윤리', '수학'];
|
||||
const TARGET_SUBJECTS: TargetSubject[] = ['수학'];
|
||||
const EXPECTED_PROBLEM_COUNT: Record<TargetSubject, number> = {
|
||||
국어: 45,
|
||||
영어: 45,
|
||||
한국사: 20,
|
||||
'생활과 윤리': 20,
|
||||
수학: 30,
|
||||
};
|
||||
|
||||
@@ -168,13 +164,6 @@ async function processProblemSet(
|
||||
problemSet.problems,
|
||||
);
|
||||
|
||||
if (!options.answersOnly) {
|
||||
const missingProblemUpdate = await collectMissingProblemUpdate(problemSet, files.problemPdfPath);
|
||||
if (missingProblemUpdate) {
|
||||
problemUpdates.push(missingProblemUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
const skipped =
|
||||
answerUpdates.filter((update) => !update.shouldWrite).length +
|
||||
problemUpdates.filter((update) => !update.shouldWrite).length;
|
||||
@@ -340,49 +329,6 @@ async function collectProblemUpdates(
|
||||
return updates;
|
||||
}
|
||||
|
||||
async function collectMissingProblemUpdate(
|
||||
problemSet: {
|
||||
id: number;
|
||||
year: number;
|
||||
subjectName: string;
|
||||
problems: ProblemSnapshot[];
|
||||
},
|
||||
problemPdfPath: string,
|
||||
): Promise<ProblemCreate | null> {
|
||||
if (problemSet.subjectName !== '한국사') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expectedNumber = 20;
|
||||
if (problemSet.problems.some((problem) => problem.number === expectedNumber)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pageCount = getPdfPageCount(problemPdfPath);
|
||||
const pageNumber =
|
||||
findProblemPageByText(problemPdfPath, pageCount, expectedNumber) ??
|
||||
estimateProblemPage('한국사', expectedNumber, pageCount);
|
||||
const ocrProblems = await ocrProblemPage(problemPdfPath, pageNumber, [expectedNumber]);
|
||||
const ocrProblem = ocrProblems.find((problem) => problem.number === expectedNumber);
|
||||
|
||||
if (!ocrProblem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const choices = normalizeChoiceRecord(ocrProblem.choices);
|
||||
return {
|
||||
kind: 'create',
|
||||
number: expectedNumber,
|
||||
title: `${problemSet.year} 한국사 ${expectedNumber}번`,
|
||||
difficulty: 0,
|
||||
bodyText: ocrProblem.bodyText,
|
||||
choices,
|
||||
answerNumber: null,
|
||||
needsReview: computeNeedsReview(ocrProblem.bodyText, choices, null),
|
||||
shouldWrite: true,
|
||||
};
|
||||
}
|
||||
|
||||
function printDryRun(
|
||||
year: number,
|
||||
subject: TargetSubject,
|
||||
@@ -405,18 +351,6 @@ function printDryRun(
|
||||
}
|
||||
|
||||
function resolveSubjectFiles(year: number, subject: TargetSubject): SubjectFiles {
|
||||
if (subject === '생활과 윤리') {
|
||||
return {
|
||||
subject,
|
||||
answerPdfPath: requireExistingFile(
|
||||
path.join(DATA_DIR, String(year), '사회탐구/사회탐구영역_정답표', '01 생활과 윤리_정답표.pdf'),
|
||||
),
|
||||
problemPdfPath: requireExistingFile(
|
||||
path.join(DATA_DIR, String(year), '사회탐구/사회탐구영역_문제지', '01 생활과 윤리_문제지.pdf'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const subjectDir = path.join(DATA_DIR, String(year), subject);
|
||||
const answerPdfPath = findFile(subjectDir, /_정답표?\.pdf$/);
|
||||
const problemPdfPath =
|
||||
@@ -488,8 +422,12 @@ function findProblemPageByText(
|
||||
return null;
|
||||
}
|
||||
|
||||
function estimateProblemPage(subject: TargetSubject, problemNumber: number, pageCount: number): number {
|
||||
const problemsPerPage = subject === '한국사' || subject === '생활과 윤리' ? 10 : 4;
|
||||
function estimateProblemPage(
|
||||
_subject: TargetSubject,
|
||||
problemNumber: number,
|
||||
pageCount: number,
|
||||
): number {
|
||||
const problemsPerPage = 4;
|
||||
return Math.max(1, Math.min(pageCount, Math.ceil(problemNumber / problemsPerPage)));
|
||||
}
|
||||
|
||||
|
||||
216
backend/scripts/wipe-non-math.ts
Normal file
216
backend/scripts/wipe-non-math.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import * as fs from 'fs';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
|
||||
interface CliOptions {
|
||||
dryRun: boolean;
|
||||
backupPath?: string;
|
||||
}
|
||||
|
||||
interface TableCounts {
|
||||
problemSets: number;
|
||||
problems: number;
|
||||
passages: number;
|
||||
studyLogs: number;
|
||||
reviewSchedules: number;
|
||||
assignments: number;
|
||||
assignmentSubmissions: number;
|
||||
}
|
||||
|
||||
interface ProblemSetStats extends TableCounts {
|
||||
id: number;
|
||||
year: number;
|
||||
subjectName: string;
|
||||
examType: string;
|
||||
title: string;
|
||||
problemIds: number[];
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function parseArgs(): CliOptions {
|
||||
const options: CliOptions = { dryRun: true };
|
||||
for (const arg of process.argv.slice(2)) {
|
||||
if (arg === '--apply') options.dryRun = false;
|
||||
else if (arg === '--dry-run') options.dryRun = true;
|
||||
else if (arg.startsWith('--backup-path=')) options.backupPath = arg.split('=')[1];
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function emptyCounts(): TableCounts {
|
||||
return {
|
||||
problemSets: 0,
|
||||
problems: 0,
|
||||
passages: 0,
|
||||
studyLogs: 0,
|
||||
reviewSchedules: 0,
|
||||
assignments: 0,
|
||||
assignmentSubmissions: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function addCounts(target: TableCounts, delta: TableCounts): TableCounts {
|
||||
target.problemSets += delta.problemSets;
|
||||
target.problems += delta.problems;
|
||||
target.passages += delta.passages;
|
||||
target.studyLogs += delta.studyLogs;
|
||||
target.reviewSchedules += delta.reviewSchedules;
|
||||
target.assignments += delta.assignments;
|
||||
target.assignmentSubmissions += delta.assignmentSubmissions;
|
||||
return target;
|
||||
}
|
||||
|
||||
async function loadProblemSetStats(): Promise<ProblemSetStats[]> {
|
||||
const sets = await prisma.problemSet.findMany({
|
||||
where: { subjectName: { not: '수학' } },
|
||||
select: {
|
||||
id: true,
|
||||
year: true,
|
||||
subjectName: true,
|
||||
examType: true,
|
||||
title: true,
|
||||
problems: { select: { id: true } },
|
||||
passages: { select: { id: true } },
|
||||
assignments: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const stats: ProblemSetStats[] = [];
|
||||
for (const set of sets) {
|
||||
const problemIds = set.problems.map((p) => p.id);
|
||||
const assignmentIds = set.assignments.map((a) => a.id);
|
||||
|
||||
const [assignmentSubmissionCount, studyLogCount, reviewScheduleCount] = await Promise.all([
|
||||
assignmentIds.length
|
||||
? prisma.assignmentSubmission.count({ where: { assignmentId: { in: assignmentIds } } })
|
||||
: Promise.resolve(0),
|
||||
problemIds.length
|
||||
? prisma.studyLog.count({ where: { problemId: { in: problemIds } } })
|
||||
: Promise.resolve(0),
|
||||
problemIds.length
|
||||
? prisma.reviewSchedule.count({ where: { studyLog: { problemId: { in: problemIds } } } })
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
|
||||
stats.push({
|
||||
...emptyCounts(),
|
||||
id: set.id,
|
||||
title: set.title,
|
||||
year: set.year,
|
||||
subjectName: set.subjectName,
|
||||
examType: set.examType,
|
||||
problemIds,
|
||||
problems: problemIds.length,
|
||||
passages: set.passages.length,
|
||||
assignments: set.assignments.length,
|
||||
assignmentSubmissions: assignmentSubmissionCount,
|
||||
studyLogs: studyLogCount,
|
||||
reviewSchedules: reviewScheduleCount,
|
||||
problemSets: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
function printStats(stats: ProblemSetStats[]): void {
|
||||
if (stats.length === 0) {
|
||||
console.log('No non-math problem sets found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const totals = emptyCounts();
|
||||
console.log(`Target problem sets: ${stats.length}`);
|
||||
for (const set of stats) {
|
||||
addCounts(totals, set);
|
||||
console.log(
|
||||
`- ${set.year} ${set.subjectName} (${set.examType}, id=${set.id}): problems=${set.problems}, passages=${set.passages}, studyLogs=${set.studyLogs}, reviewSchedules=${set.reviewSchedules}, assignments=${set.assignments}, assignmentSubmissions=${set.assignmentSubmissions}`,
|
||||
);
|
||||
}
|
||||
console.log('\nPlanned deletions (rows):');
|
||||
console.log(JSON.stringify(totals, null, 2));
|
||||
}
|
||||
|
||||
async function deleteProblemSet(
|
||||
tx: Prisma.TransactionClient,
|
||||
set: ProblemSetStats,
|
||||
): Promise<TableCounts> {
|
||||
const totals = emptyCounts();
|
||||
if (set.problemIds.length > 0) {
|
||||
const reviewCount = await tx.reviewSchedule.deleteMany({
|
||||
where: { studyLog: { problemId: { in: set.problemIds } } },
|
||||
});
|
||||
totals.reviewSchedules += reviewCount.count;
|
||||
const studyLogCount = await tx.studyLog.deleteMany({ where: { problemId: { in: set.problemIds } } });
|
||||
totals.studyLogs += studyLogCount.count;
|
||||
}
|
||||
|
||||
const assignmentSubmissionCount = await tx.assignmentSubmission.deleteMany({
|
||||
where: { assignment: { problemSetId: set.id } },
|
||||
});
|
||||
totals.assignmentSubmissions += assignmentSubmissionCount.count;
|
||||
|
||||
const assignmentCount = await tx.assignment.deleteMany({ where: { problemSetId: set.id } });
|
||||
totals.assignments += assignmentCount.count;
|
||||
|
||||
const passageCount = await tx.passage.deleteMany({ where: { problemSetId: set.id } });
|
||||
totals.passages += passageCount.count;
|
||||
|
||||
if (set.problemIds.length > 0) {
|
||||
const problemCount = await tx.problem.deleteMany({ where: { id: { in: set.problemIds } } });
|
||||
totals.problems += problemCount.count;
|
||||
}
|
||||
|
||||
await tx.problemSet.delete({ where: { id: set.id } });
|
||||
totals.problemSets += 1;
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
async function applyDeletes(stats: ProblemSetStats[]): Promise<TableCounts> {
|
||||
const totals = emptyCounts();
|
||||
if (stats.length === 0) return totals;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const set of stats) {
|
||||
const deleted = await deleteProblemSet(tx, set);
|
||||
addCounts(totals, deleted);
|
||||
console.log(`Deleted ${set.year} ${set.subjectName} (id=${set.id})`);
|
||||
}
|
||||
});
|
||||
return totals;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs();
|
||||
if (!options.dryRun) {
|
||||
if (!options.backupPath) {
|
||||
throw new Error('`--backup-path=<path>` is required when running with --apply');
|
||||
}
|
||||
if (!fs.existsSync(options.backupPath)) {
|
||||
throw new Error(`Backup path not found: ${options.backupPath}`);
|
||||
}
|
||||
console.log(`Using backup file at ${options.backupPath}`);
|
||||
} else if (options.backupPath) {
|
||||
console.warn('`--backup-path` is ignored during dry-run');
|
||||
}
|
||||
|
||||
const stats = await loadProblemSetStats();
|
||||
printStats(stats);
|
||||
|
||||
if (options.dryRun) {
|
||||
await prisma.$disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const totals = await applyDeletes(stats);
|
||||
console.log('\nDeletion results (rows removed):');
|
||||
console.log(JSON.stringify(totals, null, 2));
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
console.error(error);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -32,7 +32,7 @@ import { AuthUser } from '../auth/jwt.strategy';
|
||||
import { MeService } from './me.service';
|
||||
|
||||
const ALLOWED_AVATAR_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;
|
||||
const ALLOWED_FOCUS_SUBJECTS = ['국어', '수학', '영어', '한국사', '탐구'] as const;
|
||||
const ALLOWED_FOCUS_SUBJECTS = ['수학'] as const;
|
||||
const AVATAR_UPLOAD_DIR = join(__dirname, '..', '..', 'uploads', 'avatars');
|
||||
|
||||
function ensureAvatarUploadDir() {
|
||||
@@ -63,8 +63,8 @@ class OnboardingDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(2)
|
||||
@ArrayMaxSize(4)
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(1)
|
||||
@IsIn(ALLOWED_FOCUS_SUBJECTS, { each: true })
|
||||
focusSubjects?: string[];
|
||||
|
||||
@@ -101,8 +101,8 @@ class ProfilePatchDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(2)
|
||||
@ArrayMaxSize(4)
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(1)
|
||||
@IsIn(ALLOWED_FOCUS_SUBJECTS, { each: true })
|
||||
focusSubjects?: string[];
|
||||
|
||||
|
||||
@@ -1,51 +1,8 @@
|
||||
import { kiceMathStrategy } from './math';
|
||||
import { PageStripStrategy } from '../types';
|
||||
|
||||
export const kiceStrategy: PageStripStrategy = {
|
||||
...kiceMathStrategy,
|
||||
name: 'kice',
|
||||
format: 'kice',
|
||||
maxProblemNumber: 45,
|
||||
chromeLinePatterns: [
|
||||
/^\s*\d+\s*$/,
|
||||
/^\s*\d+\s+홀수형\s*$/,
|
||||
/^\s*\d+\s+짝수형\s*$/,
|
||||
/^\s*홀수형\s+\d+\s*$/,
|
||||
/^\s*짝수형\s+\d+\s*$/,
|
||||
/^\s*홀수형\s*$/,
|
||||
/^\s*짝수형\s*$/,
|
||||
/^\s*제\s*\d+\s*교시\s*.*$/,
|
||||
/^\s*\d{4}학년도.*대학수학능력시험.*문제지.*$/,
|
||||
/^\s*이 문제지에 관한 저작권은.*$/,
|
||||
/^\s*(?:국어|영어|수학|한국사)\s+영역\s*$/,
|
||||
/^\s*(?:국어|영어|수학|한국사)\s+영역\s+\d+\s*$/,
|
||||
/^\s*(?:국어|영어|수학|한국사)\s+영역\s+홀수형\s*$/,
|
||||
/^\s*(?:국어|영어|수학|한국사)\s+영역\s+짝수형\s*$/,
|
||||
/^\s*홀수형\s+(?:국어|영어|수학|한국사)\s+영역\s+\d+\s*$/,
|
||||
/^\s*짝수형\s+(?:국어|영어|수학|한국사)\s+영역\s+\d+\s*$/,
|
||||
/^\s*사회탐구\s+영역\s*$/,
|
||||
/^\s*사회탐구\s+영역\s+\d+\s*$/,
|
||||
/^\s*사회탐구\s+영역\s*\(.+\)\s*$/,
|
||||
/^\s*\(.+\)\s+\d+\s*$/,
|
||||
/^\s*\(.+\)\s*$/,
|
||||
/^\s*\(.+\)\s+홀수형\s*$/,
|
||||
/^\s*\(.+\)\s+짝수형\s*$/,
|
||||
/^\s*\d+\s+\(.+\)\s+홀수형\s*$/,
|
||||
/^\s*\d+\s+\(.+\)\s+짝수형\s*$/,
|
||||
/^\s*홀수형\s+\(.+\)\s+\d+\s*$/,
|
||||
/^\s*짝수형\s+\(.+\)\s+\d+\s*$/,
|
||||
/^\s*생활과\s+윤리\s*$/,
|
||||
/^\s*성명\s+수험\s+번호.*$/,
|
||||
/^\s*\*\s*확인\s*사항\s*$/,
|
||||
/^\s*◦\s*답안지의 해당란에 필요한 내용을 정확히 기입\(표기\)했는지 확인\s*$/,
|
||||
/^\s*◦\s*이어서,\s*「선택과목\(.+\)」 문제가 제시되오니,\s*자신이\s*$/,
|
||||
/^\s*선택한 과목인지 확인하시오\.\s*$/,
|
||||
],
|
||||
leakedChromePatterns: [
|
||||
/대학수학능력시험\s+문제지/,
|
||||
/이 문제지에 관한 저작권은/,
|
||||
/(?:^|\s)홀수형(?:\s|$)/,
|
||||
/(?:^|\s)짝수형(?:\s|$)/,
|
||||
/제\s*\d+\s*교시/,
|
||||
],
|
||||
evenFormSplitPattern: /\(\s*짝수\s*\)\s*형|짝수형/,
|
||||
answerNumberPattern: /(\d{1,2})\s*[번]?\s*([①②③④⑤])/g,
|
||||
};
|
||||
|
||||
@@ -529,11 +529,7 @@ function clamp01(n: number): number {
|
||||
|
||||
function defaultSubjectColor(subjectName: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
국어: "#ef4444",
|
||||
수학: "#3b82f6",
|
||||
영어: "#22c55e",
|
||||
한국사: "#f59e0b",
|
||||
"생활과 윤리": "#f59e0b",
|
||||
};
|
||||
|
||||
return colorMap[subjectName] ?? "#6366f1";
|
||||
|
||||
Reference in New Issue
Block a user