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:
reloop
2026-04-14 18:27:00 +09:00
parent 1f04283c19
commit d892d8298b
11 changed files with 532 additions and 265 deletions

View File

@@ -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 백업 필수
### 범위 / 비 범위

View File

@@ -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",

View File

@@ -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`);

View File

@@ -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)
/// 파싱 엣지케이스 플래그 — 수동 보정 대상

View File

@@ -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() {

View File

@@ -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;
}
}
}

View File

@@ -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)));
}

View 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);
});

View File

@@ -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[];

View File

@@ -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,
};

View File

@@ -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";