diff --git a/Plans.md b/Plans.md index 5087faa..82ffac4 100644 --- a/Plans.md +++ b/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 백업 필수 + ### 범위 / 비 범위 diff --git a/backend/package.json b/backend/package.json index f897a72..f5f3c59 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/prisma/migrations/20260414091258_phase10_math_units_and_ps/migration.sql b/backend/prisma/migrations/20260414091258_phase10_math_units_and_ps/migration.sql new file mode 100644 index 0000000..6db09b8 --- /dev/null +++ b/backend/prisma/migrations/20260414091258_phase10_math_units_and_ps/migration.sql @@ -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`); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 913837a..b605a1e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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) /// 파싱 엣지케이스 플래그 — 수동 보정 대상 diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 67bda0b..d242662 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -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() { diff --git a/backend/scripts/kice-import.ts b/backend/scripts/kice-import.ts index cb2b096..f6a78ee 100644 --- a/backend/scripts/kice-import.ts +++ b/backend/scripts/kice-import.ts @@ -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 = { - 국어: 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; +} + +interface MathExamBundle { + parsed: ParseResult; + answerMap: Map; + 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(); 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, -): Promise<{ problems: number; passages: number; needsReview: number; warnings: string[] }> { +): Promise { 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 { + 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, +): Promise { 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; + } } } diff --git a/backend/scripts/ocr-fix.ts b/backend/scripts/ocr-fix.ts index 198b68a..cd862be 100644 --- a/backend/scripts/ocr-fix.ts +++ b/backend/scripts/ocr-fix.ts @@ -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 = { - 국어: 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 { - 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))); } diff --git a/backend/scripts/wipe-non-math.ts b/backend/scripts/wipe-non-math.ts new file mode 100644 index 0000000..916f2e7 --- /dev/null +++ b/backend/scripts/wipe-non-math.ts @@ -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 { + 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 { + 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 { + 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=` 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); +}); diff --git a/backend/src/me/me.controller.ts b/backend/src/me/me.controller.ts index d74b703..96c865c 100644 --- a/backend/src/me/me.controller.ts +++ b/backend/src/me/me.controller.ts @@ -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[]; diff --git a/backend/src/problem-sets/parsing/strategies/kice.ts b/backend/src/problem-sets/parsing/strategies/kice.ts index 7857eeb..87504fd 100644 --- a/backend/src/problem-sets/parsing/strategies/kice.ts +++ b/backend/src/problem-sets/parsing/strategies/kice.ts @@ -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, }; diff --git a/backend/src/study-logs/study-logs.service.ts b/backend/src/study-logs/study-logs.service.ts index 6ee1dc1..69b0614 100644 --- a/backend/src/study-logs/study-logs.service.ts +++ b/backend/src/study-logs/study-logs.service.ts @@ -529,11 +529,7 @@ function clamp01(n: number): number { function defaultSubjectColor(subjectName: string): string { const colorMap: Record = { - 국어: "#ef4444", 수학: "#3b82f6", - 영어: "#22c55e", - 한국사: "#f59e0b", - "생활과 윤리": "#f59e0b", }; return colorMap[subjectName] ?? "#6366f1";