commit 02d9d0785bb669eada53afe142b39c8e5c2806d6 Author: seung6lee Date: Mon Aug 10 21:35:17 2026 +0900 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..92ddad3 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Google AI Studio +GEMINI_API_KEY="" + +# 선택: 사용할 모델 (기본값 gemini-3.5-flash-lite) +GEMINI_MODEL=gemini-3.5-flash-lite + +# 선택: 서버 포트 (기본값 1234) +PORT=1234 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55eee04 --- /dev/null +++ b/.gitignore @@ -0,0 +1,178 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# CogMind 시험 기록 +data/ diff --git a/Metacognitive ability.md b/Metacognitive ability.md new file mode 100644 index 0000000..3f77c45 --- /dev/null +++ b/Metacognitive ability.md @@ -0,0 +1,52 @@ + +# 메타인지란 +MetaCognitive ability(메타인지)란 meta(초월)와 cognitive(인지)의 합성어로 인지에 대한 인지를 하는 능력으로 1976년, 미국의 심리학자 존 플래벌(John Flavell)에 의해 처음 도입되었다. 이를 쉽게 풀어말하자면, 내가하는 생각에 대해서 생각하는 것이다. + +이는 크게 **메타인지 지식**, **메타인지 조절** 로 나뉜다. +## 메타인지 지식 +메타인지 지식은 개인이 자신의 인지 과정을 얼마나 잘 파악하고 있는지 나타낸다. 이를 통해 자신의 학습 과정과 방법을 더 잘 이해할 수 있다. 이는 더 세분화되어 **선언적 지식**, **절차적 지식**, **조건적 지식** 유형으로 나뉜다. +### 선언전 지식 +선언적 지식은 자신이 학습하는 내용에서 어떤 개념, 사실, 정보를 아는지에 대한 지식이다. +### 절차적 지식 +절차적 지식이란 특정 작업의 순서나 절차를 새우고, 알고리즘을 만드는 능력이다. +### 조건적 지식 +조건적 지식이란 상황에 알맞은 지식이나 절차를 선택하는 능력이다. + +## 메타인지 조절 +메타인지 조절은 자신의 학습 상황을 분석하는 능력이다. 이를 통해서는 자신의 학습 태도, 상황들에 대한 이해도를 높일 수 있으며 학습 과정을 효과적으로 개선할 수 있다. 이 또한 **계획**, **모니터링**, **평가** 로 나뉜다. +### 계획 +학습이나, 문제 풀이를 시작하기전 어떻게 진행할지 구상하는 능력이다. +### 모니터링 +학습을 진행하는 동안 자신의 상태를 감시하고 분석하는 능력이다. +### 평가 +학습이 끝난 후, 그 결과를 평가하는 능력이다. + +___ + +# 메타인지의 활용 +메타인지는 그 자체로 인간의 정신적 발전에 굉장히 뛰어난 개념이기에 수많은 사람들이 활용활 수 있다. 그중에서도 메타인지는 많은 학습을 해야하는 학생들에게 뛰어난 활용성을 지닌다. 학생은 #메타인지 지식을 통해 자신의 현재 학습 성취도를 분석할 수 있으며, #메타인지 조절을 통해 자신의 공부 방법을 조절할 수 있다. + +그럼, 학습에서의 메타인지 적용 단계에 대해 더 자세히 설명해보겠다. + +먼저 메타인지 지식은 학습을 하는 동안 이용된다. +1. 선언적 지식 : 개념을 공부할 때 해당 개념에 대한 이해도를 평가한다. +2. 절차적 지식 : 문제 풀이를 할 때, 정확한 절차를 이용하는지 평가한다. +3. 조건적 지식 : 시험에서 각 문제마다 올바른 절차를 세우는데 이용된다. + +메타인지 조절은 학습을 보다 더 효율적으로 할 수 있게 해준다. +1. 계획 : 지난 학습을 바탕으로 나에게 부족한 부분을 보충할 수 있게 계획을 세운다. +2. 모니터링 : 메타인지 지식을 활용하면서 학습을 진행하며, 학습 도중 내가 메타인지 지식을 잘 활용하고 있는지 확인한다. +3. 평가 : 계획과 일치하는 결과가 도출되었고, 모니터링 결과가 우수한지 확인한다. 이를 바탕으로 다음 학습에 더욱더 발전된 계획을 세울 수 있다. + +___ + +# 우리 서비스에 적용 +위와 같이 학습을 할 때, 메타인지를 잘 활용한다면 좋은 결과를 얻어 공부의 효율성을 향상시킬 수 있으면을 수 있겠지만, 메타인지가 부족한 경우에는 반대의 결과가 도출된다. + +따라서, 우리는 메타인지 능력이 부족한 학생들에게 메타인지 과정을 도와주는 서비스 CogMind를 구상하였다. CogMind는 온라인 서비스이기 때문에 조건적 지식, 모니터링을 제외한 나머지 메타인지 유형을 바탕으로 구상하였다. 그리하여, 서비스에는 크게 테스트 파트|테스트 파트(메타인지 지식)과 피드백 파트|피드백 및 계획을 제공해주는 파트(메타인지 조절)로 나눠진다. + +## 테스트 파트 +이 파트는 학생들의 학업 성취도를 평가하기 위한 테스트를 생성하는 과정이다. 이 때, 테스트는 다양한 개념의 문제들을 포함하여, 학생의 종합적인 성취도를 확인할 수 있어야 하며, 각각의 개념에 대해 정확하게 아는지 평가하기 위한 문제선정 알고리즘이 필요하며 이를 도와주기 위해 인공지능이 필요하다. + +## 피드백 파트 +위의 테스트를 통한 결과를 분석하고 분석을 기반으로 학생의 학업 성취에 대한 피드백과 분석 결과를 제공한다. 이때, 정확하고 명료한 피드백을 제공하기 위해서 피드백에 대한 설문과 학습이 필요하다. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0228455 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# CogMind (web) + +문제를 풀고 **확신도**를 함께 기록하면, AI가 "안다고 착각한 개념"을 짚어 주는 메타인지 학습 서비스입니다. + +LLM은 Google Gen AI SDK(`@google/genai`, 기본 모델 `gemini-2.5-flash`)를 사용합니다. + +## 실행 + +```bash +bun install + +cp .env.example .env # GEMINI_API_KEY 를 채웁니다 +bun run start # http://127.0.0.1:1234 +``` + +API 키는 [Google AI Studio](https://aistudio.google.com/apikey)에서 발급합니다. +`.env` 는 `.gitignore` 에 포함되어 있으니 키를 코드에 직접 적지 마세요. + +개발 중에는 `bun run dev` (파일 변경 시 자동 재시작), 타입 검사는 `bun run typecheck`. + +## 화면 주소 + +History API 로 화면마다 주소를 갖습니다. 브라우저 뒤로/앞으로 가기가 그대로 동작하고, +주소를 직접 열거나 새로고침해도 같은 화면이 뜹니다 (`server.ts` 의 SPA 폴백). + +| 주소 | 화면 | +| --- | --- | +| `/` | 시작 화면 | +| `/quiz/3` | 풀고 있는 시험의 3번 문제 (문항마다 뒤로가기 한 단계) | +| `/tests/:id` | 시험 결과 리포트 — 새로고침·공유 가능 | +| `/history` | 지난 기록 목록 | + +진행 중인 시험은 메모리에만 있으므로, 새로고침한 뒤의 `/quiz/*` 는 시작 화면으로 보냅니다. + +## 서버 API + +| 엔드포인트 | 설명 | +| --- | --- | +| `GET /api/chapters` | `chapter.json` (학교 종류 → 과목 → 단원) | +| `POST /api/questions` | `{ school, subject, chapter, difficulty, amount }` → 문항 생성 | +| `POST /api/tests` | 시험 결과 저장 → 저장된 기록 전체 반환 | +| `GET /api/tests` | 기록 목록 (요약) | +| `GET /api/tests/:id` | 기록 상세 | +| `POST /api/tests/:id/feedback` | 메타인지 평가·학습 추천 생성 후 기록에 저장 | +| `POST /api/tests/:id/questions/:index/explain` | 해설 생성 후 해당 문항에 저장 | +| `POST /api/tests/:id/questions/:index/twin` | 쌍둥이 문제 생성 후 해당 문항의 `children` 에 추가 | +| `POST /api/tests/:id/questions/:index/ask` | 해설에 이어지는 후속 질문. 질문·답변을 `conversation` 에 추가 | + +해설과 피드백은 한 번 만들어지면 그대로 재사용합니다(중복 호출 시 저장된 값 반환). +실패 시 `4xx/502` 와 `{ error }` 를 돌려줍니다. + +## 기록 저장 형식 + +기록은 `data/history.json` 한 파일에 배열로 쌓입니다 (`.gitignore` 대상). + +```jsonc +{ + "id": "msmjphyx-xj7nbf", + "createdAt": "2026-08-10T01:21:13.785Z", + "student": "김민준", + "school": "고등학교", "subject": "수학", "chapter": "도함수", + "difficulty": "medium", + "score": { "total": 3, "correct": 2, "percent": 67, + "overconfident": 1, "lucky": 1, "solid": 1 }, + "feedback": { "summary": "...", "detail": "...", "recommendation": "..." }, + "questions": [ + { + "question": "...", "correct_answer": "...", "incorrect_answers": ["..."], + "user_answer": "...", "confidence": "sure", "is_correct": false, + "explanation": "AI 해설 (key/value)", + "conversation": [ // 해설에 이어서 주고받은 질문·답변 + { "role": "user", "content": "왜 그런가요?", "createdAt": "..." }, + { "role": "assistant", "content": "...", "createdAt": "..." } + ], + "children": [ // 쌍둥이 문제 + { "question": "...", "correct_answer": "...", + "incorrect_answers": ["..."], "createdAt": "..." } + ] + } + ] +} +``` + +## 구조 + +``` +server.ts express 서버 + Gemini 호출 +store.ts data/history.json 기반 기록 저장소 +chapter.json 학교 종류 → 과목 → 단원 +public/index.html 앱 셸 +public/images/ logo-light.png (밝은 테마) · logo-dark.png (어두운 테마) +public/main.css 디자인 토큰과 스타일 (라이트/다크 자동) +public/main.js 상태, 서버 통신, 화면 전환 +public/report.js 리포트 렌더러 (결과 화면과 기록 상세가 공용) +public/optionPage.js 시작 화면 () +public/testPage.js 퀴즈 화면 () +public/historyPage.js 지난 기록 목록 () +``` diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..696a27d Binary files /dev/null and b/bun.lockb differ diff --git a/chapter.json b/chapter.json new file mode 100644 index 0000000..5446efa --- /dev/null +++ b/chapter.json @@ -0,0 +1,52 @@ +{ + "고등학교": { + "수학": [ + "다항식", + "방정식과 부등식", + "도형의 방정식", + "집합과 명제", + "함수", + "경우의 수", + "지수함수와 로그", + "삼각함수", + "수열", + "함수의 극한과 연속", + "다항항수의 미분법", + "다항항수의 적분법", + "급수", + "도함수", + "행렬" + ], + "과학": [ + "힘과 운동", + "에너지와 열", + "시간과 공간", + "물질의 전기적 특성", + "물질의 자기적 특성" + ] + }, + "중학교": { + "수학": [ + "소인수분해", + "정수와 유리수", + "방정식", + "그래프와 비례", + "기본도형", + "평면도형", + "입체도형", + "통계", + "수와 식", + "부등식", + "방정식", + "함수", + "도형의 성질", + "도형의 닮음", + "피타고라스 정리", + "확률", + "제곱근과 실수", + "다항식의 곱셈과 인수분해", + "이차방정식", + "이차함수" + ] + } +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..a94d1ed --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +bun = "latest" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d8c8424 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1063 @@ +{ + "name": "lg", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lg", + "dependencies": { + "bun": "^1.1.2", + "express": "^4.19.2", + "request-ip": "^3.3.0" + }, + "devDependencies": { + "@types/bun": "latest", + "ts-node": "^10.9.2", + "typescript": "^5.4.4" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@oven/bun-darwin-aarch64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.1.2.tgz", + "integrity": "sha512-p8X4rf9An74sBaF6VxgTp/zJxPqKtCO1XljGnbokRk/speOSoB70JwRZ/QX4eI7TQ0yA4Kunp9LsLOxUCRzW6w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.1.2.tgz", + "integrity": "sha512-t2XGyXTCpy1kqiYo0UOM5UuSI3uPfRes3yg7TQ5wSJ+Co0rQScgEys0YEai39k/1+TlMuBh1xfMZfUbenMxLaA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64-baseline": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.1.2.tgz", + "integrity": "sha512-M/4VZyG+bCfoL9/yC9mqWBAOvVNZTDRsnWFcBorC3JEi5g4yNNcuEf6s2sW3kA1pS+C7aNH0XX/XsBJI0/C1tA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-linux-aarch64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.1.2.tgz", + "integrity": "sha512-lZ9UPDR8MNhFOYZPH/EvUyW5k34mX3vYi+1xJYDb5upD3z3F2LVtfBuoy9ndT5H+S9CxbDVwrg9OhO9HpubkBg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.1.2.tgz", + "integrity": "sha512-nM3d5InrSxcFPjLkkJiEj45U6wk5la6FEwDL4GIaVrEIC+zv9TCACm2IlI1Se823Gk8h/0nR7hQPbiSc/uBGmQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-baseline": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.1.2.tgz", + "integrity": "sha512-e82Vqe3JnKh7gOZG3ZlKDzn/fYgZ5If6lQcGwBQlB3qKHdL3biSdnWUoycxigqY99hGuMLWPC07Oi7PGsPM+3w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-windows-x64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.1.2.tgz", + "integrity": "sha512-y84cNra4LA90YR1iBThHmXdV1/A1pKjrO/XifoesbwvX0LoyzV00KKX2DRNowMY1Z6GZI2ITdrKoOhYoojY0cg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oven/bun-windows-x64-baseline": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.1.2.tgz", + "integrity": "sha512-pVCUXC/LvyhUJKporOjobhTCzIatX8oF+YGjeF99duEgAWZmx4o6NOZfak29eK+KCGJlj3gKkxRq4qdO3GSI1Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/bun": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.0.12.tgz", + "integrity": "sha512-qPb5FcygbpSS1NDBjWyQCWeI9kKXwSYSR1Enu7yb+gMXgFwGMhlyOvgV/7FGrdvAjlSXWRY6IDepos7k8WzAtQ==", + "dev": true, + "dependencies": { + "bun-types": "1.0.36" + } + }, + "node_modules/@types/node": { + "version": "20.12.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.5.tgz", + "integrity": "sha512-BD+BjQ9LS/D8ST9p5uqBxghlN+S42iuNxjsUGjeZobe/ciXzk2qb1B6IXc6AnRLS+yFJRpN2IPEHMzwspfDJNw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bun": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/bun/-/bun-1.1.2.tgz", + "integrity": "sha512-18RVJexMTdkCnihCJGSy0i5W9rHp3YdCUH+BpqRMv+xMr0ZC5y33WQoQTTMMpaSiuVcWckddetzxtMEnBsqL4w==", + "cpu": [ + "arm64", + "x64" + ], + "hasInstallScript": true, + "os": [ + "darwin", + "linux", + "win32" + ], + "bin": { + "bun": "bin/bun.exe", + "bunx": "bin/bun.exe" + }, + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "1.1.2", + "@oven/bun-darwin-x64": "1.1.2", + "@oven/bun-darwin-x64-baseline": "1.1.2", + "@oven/bun-linux-aarch64": "1.1.2", + "@oven/bun-linux-x64": "1.1.2", + "@oven/bun-linux-x64-baseline": "1.1.2", + "@oven/bun-windows-x64": "1.1.2", + "@oven/bun-windows-x64-baseline": "1.1.2" + } + }, + "node_modules/bun-types": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.0.36.tgz", + "integrity": "sha512-gaIb1SyhB0JZfIEg73/kSFhqolUqJXC68peguhXGwqr27HuvI8nkD0LTIHp/1DY4cNadfXHYgYrZIWX7oEoXlg==", + "dev": true, + "dependencies": { + "@types/node": "~20.11.3", + "@types/ws": "~8.5.10" + } + }, + "node_modules/bun-types/node_modules/@types/node": { + "version": "20.11.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz", + "integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.2", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/request-ip": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/request-ip/-/request-ip-3.3.0.tgz", + "integrity": "sha512-cA6Xh6e0fDBBBwH77SLJaJPBmD3nWVAcF9/XAcsrIHdjhFzFiB5aNQFytdjCGPezU3ROwrR11IddKAM08vohxA==" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.4.tgz", + "integrity": "sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fd65f5a --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "cogmind", + "module": "server.ts", + "type": "module", + "scripts": { + "start": "bun run server.ts", + "dev": "bun --watch server.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/express": "^4.17", + "ts-node": "^10.9.2", + "typescript": "^5.4.4" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@google/genai": "^2.16.0", + "bun": "^1.1.2", + "express": "^4.19.2" + } +} diff --git a/public/historyPage.js b/public/historyPage.js new file mode 100644 index 0000000..b869fbb --- /dev/null +++ b/public/historyPage.js @@ -0,0 +1,125 @@ +class HistoryPage extends HTMLElement { + async connectedCallback() { + this.className = 'screen'; + this.replaceChildren(createTopbar([createButton('새 시험 시작', () => showStartScreen())])); + + const heading = document.createElement('h1'); + heading.className = 'page-title'; + heading.textContent = '지난 기록'; + this.appendChild(heading); + + const body = document.createElement('div'); + body.innerHTML = `
+ 기록을 불러오는 중이에요…
`; + this.appendChild(body); + + let tests; + try { + ({ tests } = await api.tests()); + } catch (err) { + body.replaceChildren(); + const error = document.createElement('p'); + error.className = 'error-box'; + error.textContent = err.message; + body.appendChild(error); + return; + } + + if (!tests.length) { + body.replaceChildren(); + const empty = document.createElement('div'); + empty.className = 'card empty'; + empty.innerHTML = `

아직 저장된 기록이 없습니다.

`; + empty.appendChild( + createButton('첫 시험 시작하기', () => showStartScreen(), 'primary') + ); + body.appendChild(empty); + return; + } + + body.replaceChildren(this.renderStats(tests), this.renderList(tests)); + } + + renderStats(tests) { + const totals = tests.reduce( + (acc, test) => ({ + questions: acc.questions + test.score.total, + correct: acc.correct + test.score.correct, + overconfident: acc.overconfident + test.score.overconfident, + }), + { questions: 0, correct: 0, overconfident: 0 } + ); + + const card = document.createElement('div'); + card.className = 'card'; + card.innerHTML = ` +
+
본 시험
+
푼 문제
+
평균 정답률
+
+ 확신했지만 틀림 +
+
+ `; + const tiles = card.querySelectorAll('.insight strong'); + tiles[0].textContent = tests.length; + tiles[1].textContent = totals.questions; + tiles[2].textContent = `${Math.round((totals.correct / totals.questions) * 100)}%`; + tiles[3].textContent = totals.overconfident; + card.querySelector('.insight').classList.toggle('risk', totals.overconfident > 0); + return card; + } + + renderList(tests) { + const list = document.createElement('div'); + list.className = 'result-list'; + + tests.forEach((test) => { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'card history-item'; + item.onclick = () => showHistoryDetail(test.id); + + const top = document.createElement('div'); + top.className = 'history-top'; + const range = document.createElement('span'); + range.className = 'history-range'; + range.textContent = `${test.school} ${test.subject} · ${test.chapter}`; + const percent = document.createElement('span'); + percent.className = 'history-score'; + percent.textContent = `${test.score.percent}점`; + top.append(range, percent); + + const meta = document.createElement('p'); + meta.className = 'muted'; + meta.textContent = + `${test.student} · ${formatDate(test.createdAt)} · ` + + `난이도 ${DIFFICULTY_LABEL[test.difficulty] ?? test.difficulty} · ` + + `${test.score.total}문제 중 ${test.score.correct}문제 정답`; + + const tags = document.createElement('p'); + tags.className = 'history-tags'; + const badges = []; + if (test.score.overconfident) { + badges.push(['bad', `확신했지만 틀림 ${test.score.overconfident}`]); + } + if (test.explainedCount) badges.push(['', `해설 ${test.explainedCount}`]); + if (test.twinCount) badges.push(['', `쌍둥이 문제 ${test.twinCount}`]); + if (test.askedCount) badges.push(['', `후속 질문 ${test.askedCount}`]); + badges.forEach(([tone, label]) => { + const badge = document.createElement('span'); + badge.className = `badge ${tone}`.trim(); + badge.textContent = label; + tags.appendChild(badge); + }); + + item.append(top, meta, tags); + list.appendChild(item); + }); + + return list; + } +} + +customElements.define('history-page', HistoryPage); diff --git a/public/images/favicon.png b/public/images/favicon.png new file mode 100644 index 0000000..7a0460f Binary files /dev/null and b/public/images/favicon.png differ diff --git a/public/images/logo-dark.png b/public/images/logo-dark.png new file mode 100644 index 0000000..3ea2f72 Binary files /dev/null and b/public/images/logo-dark.png differ diff --git a/public/images/logo-light.png b/public/images/logo-light.png new file mode 100644 index 0000000..755eebb Binary files /dev/null and b/public/images/logo-light.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..5524ca4 --- /dev/null +++ b/public/index.html @@ -0,0 +1,25 @@ + + + + + + + + + CogMind - 메타인지 학습 진단 + + + + + + +
+ + + + + + + + + diff --git a/public/main.css b/public/main.css new file mode 100644 index 0000000..c5542da --- /dev/null +++ b/public/main.css @@ -0,0 +1,728 @@ +:root { + --bg: #f4f6fb; + --surface: #ffffff; + --surface-2: #eef2f9; + --border: #dbe2ef; + --ink: #112d4e; + --ink-2: #5b6b84; + --primary: #3f72af; + --primary-ink: #ffffff; + --primary-soft: #e6eef8; + --ok: #158a5b; + --ok-soft: #e3f5ec; + --bad: #c0392b; + --bad-soft: #fdecea; + --warn: #b7791f; + --warn-soft: #fdf3e0; + --shadow: 0 1px 2px rgb(17 45 78 / 6%), 0 8px 24px rgb(17 45 78 / 8%); + --radius: 14px; + --radius-sm: 10px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0e1526; + --surface: #161f33; + --surface-2: #1d2842; + --border: #2a3752; + --ink: #e8eef9; + --ink-2: #9aabc6; + --primary: #6ea8fe; + --primary-ink: #0e1526; + --primary-soft: #1d2b47; + --ok: #4ade80; + --ok-soft: #16301f; + --bad: #f87171; + --bad-soft: #34191a; + --warn: #fbbf24; + --warn-soft: #33270e; + --shadow: 0 1px 2px rgb(0 0 0 / 30%), 0 8px 24px rgb(0 0 0 / 35%); + } +} + +* { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + min-height: 100dvh; + background: var(--bg); + color: var(--ink); + font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', + 'Segoe UI', 'Noto Sans KR', 'Malgun Gothic', sans-serif; + font-size: 16px; + line-height: 1.6; + word-break: keep-all; +} + +.screen { + display: block; + width: 100%; + max-width: 760px; + margin: 0 auto; + padding: 24px 20px 64px; + animation: fade-in 0.25s ease; +} + +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(6px); + } +} + +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + } +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 20px; +} + +h1, +h2, +h3 { + line-height: 1.3; + margin: 0; +} + +.muted { + color: var(--ink-2); + font-size: 0.9rem; +} + +button { + font: inherit; + font-weight: 600; + color: var(--ink); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 16px; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease; +} + +button:hover:not(:disabled) { + border-color: var(--primary); +} + +button:active:not(:disabled) { + transform: translateY(1px); +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +button.primary { + background: var(--primary); + border-color: var(--primary); + color: var(--primary-ink); +} + +button.primary:hover:not(:disabled) { + filter: brightness(1.08); +} + +button.block { + width: 100%; + padding: 14px; + font-size: 1.05rem; +} + +:focus-visible { + outline: 3px solid var(--primary); + outline-offset: 2px; +} + +select, +input[type='number'], +input[type='text'] { + font: inherit; + width: 100%; + padding: 11px 12px; + color: var(--ink); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +label { + display: block; + font-size: 0.85rem; + font-weight: 700; + color: var(--ink-2); + margin-bottom: 6px; +} + +.field + .field { + margin-top: 16px; +} + +.row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +@media (max-width: 520px) { + .row { + grid-template-columns: 1fr; + } +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + padding: 7px 13px; + font-size: 0.9rem; + border-radius: 999px; + background: var(--surface-2); + border: 1px solid transparent; +} + +.chip[aria-pressed='true'] { + background: var(--primary-soft); + border-color: var(--primary); + color: var(--primary); +} + +.hero { + text-align: center; + padding: 32px 0 28px; +} + +.hero img { + width: min(240px, 70%); + height: auto; +} + +.hero h1 { + font-size: 1.5rem; + margin-top: 12px; +} + +.hero p { + color: var(--ink-2); + margin: 8px 0 0; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.brand { + font-weight: 800; + color: var(--primary); + background: none; + border: 0; + padding: 0; +} + +.topbar-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.topbar-actions button { + font-size: 0.85rem; + padding: 7px 12px; +} + +.page-title { + font-size: 1.4rem; + margin: 4px 0 16px; +} + +.progress-track { + height: 8px; + background: var(--surface-2); + border-radius: 999px; + overflow: hidden; + margin-bottom: 24px; +} + +.progress-fill { + height: 100%; + width: 0; + background: var(--primary); + border-radius: 999px; + transition: width 0.3s ease; +} + +.question-text { + font-size: 1.3rem; + font-weight: 700; + margin: 0 0 20px; +} + +.options { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.option { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + text-align: left; + font-weight: 500; + padding: 14px 16px; + background: var(--surface); +} + +.option .key { + flex: 0 0 28px; + height: 28px; + display: grid; + place-items: center; + font-size: 0.85rem; + font-weight: 700; + color: var(--ink-2); + background: var(--surface-2); + border-radius: 8px; +} + +.option[aria-pressed='true'] { + border-color: var(--primary); + background: var(--primary-soft); +} + +.option[aria-pressed='true'] .key { + background: var(--primary); + color: var(--primary-ink); +} + +.confidence { + margin-top: 24px; + padding-top: 20px; + border-top: 1px dashed var(--border); +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 24px; +} + +button.ghost { + background: transparent; + color: var(--ink-2); +} + +.score { + text-align: center; + padding: 28px 20px; +} + +.score .big { + font-size: 3rem; + font-weight: 800; + line-height: 1; + color: var(--primary); +} + +.insight { + margin-top: 16px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; +} + +.insight div { + padding: 12px; + border-radius: var(--radius-sm); + background: var(--surface-2); +} + +.insight strong { + display: block; + font-size: 1.4rem; +} + +.insight.risk div.danger { + background: var(--bad-soft); + color: var(--bad); +} + +.insight div.lucky { + background: var(--warn-soft); + color: var(--warn); +} + +.insight div.solid { + background: var(--ok-soft); + color: var(--ok); +} + +.result-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 24px; +} + +.result-item { + padding: 16px; +} + +.result-head { + display: flex; + align-items: flex-start; + gap: 10px; + font-weight: 700; +} + +.badge { + flex: 0 0 auto; + font-size: 0.78rem; + font-weight: 700; + padding: 3px 9px; + border-radius: 999px; +} + +.badge.ok { + background: var(--ok-soft); + color: var(--ok); +} + +.badge.bad { + background: var(--bad-soft); + color: var(--bad); +} + +.answers { + margin: 10px 0 0; + font-size: 0.92rem; + color: var(--ink-2); +} + +.answers span { + font-weight: 600; + color: var(--ink); +} + +.result-actions { + display: flex; + gap: 8px; + margin-top: 14px; +} + +.result-actions button { + font-size: 0.9rem; + padding: 8px 12px; +} + +.ai-box { + margin-top: 12px; + padding: 14px; + border-radius: var(--radius-sm); + background: var(--surface-2); + border-left: 3px solid var(--primary); + white-space: pre-wrap; + font-size: 0.95rem; +} + +.ai-box.error { + border-left-color: var(--bad); + color: var(--bad); +} + +.badge:not(.ok):not(.bad) { + background: var(--surface-2); + color: var(--ink-2); +} + +.feedback { + margin-top: 16px; +} + +.feedback h2 { + font-size: 1.05rem; + color: var(--primary); + margin-bottom: 12px; +} + +.feedback h3 { + font-size: 0.95rem; + margin: 18px 0 6px; +} + +.feedback-summary { + font-size: 1.15rem; + font-weight: 700; + margin: 0 0 10px; +} + +.feedback-detail { + margin: 0; + white-space: pre-wrap; +} + +.twin { + margin-top: 12px; + padding: 14px; + border-radius: var(--radius-sm); + background: var(--surface-2); + border-left: 3px solid var(--ok); +} + +.twin-title { + font-weight: 700; + margin: 0 0 10px; +} + +.twin-options { + margin: 0 0 10px; + padding-left: 22px; +} + +.twin-options li { + margin-bottom: 4px; +} + +.twin details summary { + cursor: pointer; + font-size: 0.9rem; + color: var(--ink-2); + display: inline; +} + +.twin details[open] summary { + font-weight: 700; + color: var(--ok); +} + +.history-item { + display: block; + width: 100%; + text-align: left; + font-weight: 400; + padding: 16px; +} + +.history-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.history-range { + font-weight: 700; +} + +.history-score { + flex: 0 0 auto; + font-size: 1.25rem; + font-weight: 800; + color: var(--primary); +} + +.history-item .muted { + margin: 6px 0 0; +} + +.history-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0 0; +} + +.history-tags:empty { + display: none; +} + +.empty { + text-align: center; +} + +.empty p { + margin-top: 0; + color: var(--ink-2); +} + +.explanation:empty { + display: none; +} + +button.ask-toggle { + margin-top: 10px; + font-size: 0.9rem; + padding: 8px 12px; +} + +.chat { + margin-top: 12px; + padding: 14px; + border-radius: var(--radius-sm); + background: var(--surface-2); +} + +.chat[hidden] { + display: none; +} + +.chat-heading { + margin: 0 0 12px; + font-size: 0.85rem; + font-weight: 700; + color: var(--ink-2); +} + +.chat-log { + display: flex; + flex-direction: column; + gap: 8px; +} + +.chat-log:empty { + display: none; +} + +.chat-message { + max-width: 85%; + padding: 10px 14px; + border-radius: var(--radius-sm); + font-size: 0.95rem; + white-space: pre-wrap; +} + +.chat-message.user { + align-self: flex-end; + background: var(--primary); + color: var(--primary-ink); + border-bottom-right-radius: 4px; +} + +.chat-message.assistant { + align-self: flex-start; + background: var(--surface); + border: 1px solid var(--border); + border-bottom-left-radius: 4px; +} + +.chat-message.pending { + color: var(--ink-2); + font-style: italic; +} + +.chat-message.error { + background: var(--bad-soft); + color: var(--bad); +} + +.chat-form { + display: flex; + align-items: flex-end; + gap: 8px; + margin-top: 12px; +} + +.chat-form textarea { + font: inherit; + flex: 1; + min-height: 44px; + max-height: 160px; + padding: 11px 12px; + color: var(--ink); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + resize: vertical; +} + +.chat-form button { + flex: 0 0 auto; + height: 44px; +} + +.inline-loading { + display: flex; + align-items: center; + gap: 10px; + color: var(--ink-2); + font-size: 0.95rem; +} + +.spinner.small { + width: 18px; + height: 18px; + border-width: 2px; + flex: 0 0 auto; +} + +.loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + padding: 80px 20px; + text-align: center; + color: var(--ink-2); +} + +.spinner { + width: 34px; + height: 34px; + border: 3px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.dots::after { + content: ''; + animation: dots 1.4s steps(4, end) infinite; +} + +@keyframes dots { + to { + content: '...'; + } +} + +.error-box { + margin-top: 16px; + padding: 12px 14px; + border-radius: var(--radius-sm); + background: var(--bad-soft); + color: var(--bad); + font-size: 0.92rem; +} diff --git a/public/main.js b/public/main.js new file mode 100644 index 0000000..7476e77 --- /dev/null +++ b/public/main.js @@ -0,0 +1,296 @@ +const CONFIDENCE = [ + { id: 'sure', label: '확실해요' }, + { id: 'unsure', label: '애매해요' }, + { id: 'guess', label: '찍었어요' }, +]; + +const CONFIDENCE_LABEL = (id) => CONFIDENCE.find((c) => c.id === id)?.label ?? '미응답'; + +const DIFFICULTY_LABEL = { easy: '쉬움', medium: '보통', hard: '어려움' }; + +const state = { + config: {}, + questions: [], + answers: [], + confidences: [], + current: 0, + record: null, + recordKey: null, +}; + +const REQUEST_TIMEOUT = 60000; + +const request = async (url, options = {}) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); + + let res; + try { + res = await fetch(url, { ...options, signal: controller.signal }); + } catch (err) { + throw new Error( + err.name === 'AbortError' + ? '응답이 너무 오래 걸려 중단했습니다. 잠시 후 다시 시도해 주세요.' + : '서버에 연결하지 못했습니다. 서버가 실행 중인지 확인해 주세요.' + ); + } finally { + clearTimeout(timer); + } + + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `요청에 실패했습니다 (${res.status})`); + return data; +}; + +const post = (url, body) => + request(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body ?? {}), + }); + +const api = { + chapters: () => request('/api/chapters'), + questions: (config, amount) => post('/api/questions', { ...config, amount }), + saveTest: (payload) => post('/api/tests', payload), + tests: () => request('/api/tests'), + test: (id) => request(`/api/tests/${id}`), + explain: (testId, index) => post(`/api/tests/${testId}/questions/${index}/explain`), + twin: (testId, index) => post(`/api/tests/${testId}/questions/${index}/twin`), + ask: (testId, index, message) => + post(`/api/tests/${testId}/questions/${index}/ask`, { message }), + feedback: (testId) => post(`/api/tests/${testId}/feedback`), +}; + +const showScreen = (element) => { + document.getElementById('app').replaceChildren(element); + window.scrollTo({ top: 0 }); +}; + +const showLoading = (message) => { + const box = document.createElement('div'); + box.className = 'screen loading'; + box.setAttribute('role', 'status'); + box.innerHTML = `

`; + box.querySelector('p').textContent = message; + showScreen(box); +}; + +const showError = (message, onBack) => { + const box = document.createElement('div'); + box.className = 'screen'; + box.innerHTML = `

`; + box.querySelector('.error-box').textContent = message; + const back = document.createElement('button'); + back.className = 'block'; + back.style.marginTop = '16px'; + back.textContent = '처음으로'; + back.onclick = onBack ?? (() => showStartScreen()); + box.querySelector('.card').appendChild(back); + showScreen(box); +}; + +const createChipGroup = (items, { selected, onSelect } = {}) => { + const group = document.createElement('div'); + group.className = 'chips'; + group.setAttribute('role', 'group'); + + const buttons = items.map((item) => { + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'chip'; + chip.textContent = item.label; + chip.setAttribute('aria-pressed', String(item.id === selected)); + chip.onclick = () => { + buttons.forEach((b) => b.setAttribute('aria-pressed', 'false')); + chip.setAttribute('aria-pressed', 'true'); + onSelect?.(item.id); + }; + group.appendChild(chip); + return chip; + }); + + return group; +}; + +const createTopbar = (rightButtons = []) => { + const bar = document.createElement('div'); + bar.className = 'topbar'; + + const brand = document.createElement('button'); + brand.type = 'button'; + brand.className = 'brand'; + brand.textContent = 'CogMind'; + brand.onclick = () => showStartScreen(); + bar.appendChild(brand); + + const right = document.createElement('div'); + right.className = 'topbar-actions'; + rightButtons.forEach((button) => right.appendChild(button)); + bar.appendChild(right); + + return bar; +}; + +const createButton = (label, onClick, className = '') => { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + if (className) button.className = className; + button.onclick = onClick; + return button; +}; + +const formatDate = (iso) => + new Date(iso).toLocaleString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + +const shuffle = (items) => { + const copy = [...items]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; +}; + +const ROUTES = { + start: { url: () => '/', title: 'CogMind - 메타인지 학습 진단' }, + quiz: { url: (r) => `/quiz/${r.index + 1}`, title: '문제 풀이 - CogMind' }, + test: { url: (r) => `/tests/${r.id}`, title: '학습 진단 결과 - CogMind' }, + history: { url: () => '/history', title: '지난 기록 - CogMind' }, +}; + +const parseLocation = () => { + const [first, second] = location.pathname.split('/').filter(Boolean); + if (first === 'history') return { name: 'history' }; + if (first === 'tests' && second) return { name: 'test', id: second }; + if (first === 'quiz') return { name: 'quiz', index: Math.max(0, Number(second || 1) - 1) }; + return { name: 'start' }; +}; + +let pendingError = null; + +const navigate = (route, { replace = false } = {}) => { + history[replace ? 'replaceState' : 'pushState'](route, '', ROUTES[route.name].url(route)); + renderRoute(route); +}; + +const renderRoute = async (route) => { + document.title = ROUTES[route.name]?.title ?? ROUTES.start.title; + + if (route.name === 'history') { + showScreen(document.createElement('history-page')); + return; + } + + if (route.name === 'test') { + if (state.record?.id === route.id) { + showReport(state.record); + return; + } + showLoading('기록을 불러오는 중이에요'); + try { + showReport(await api.test(route.id)); + } catch (err) { + showError(err.message, () => navigate({ name: 'history' }, { replace: true })); + } + return; + } + + if (route.name === 'quiz') { + if (!state.questions.length) { + navigate({ name: 'start' }, { replace: true }); + return; + } + state.current = Math.min(route.index, state.questions.length - 1); + showScreen(document.createElement('test-page')); + return; + } + + const page = document.createElement('option-page'); + if (pendingError) { + page.dataset.error = pendingError; + pendingError = null; + } + showScreen(page); +}; + +window.addEventListener('popstate', (event) => renderRoute(event.state ?? parseLocation())); + +const showStartScreen = (errorMessage) => { + pendingError = errorMessage ?? null; + navigate({ name: 'start' }); +}; + +const showHistoryScreen = () => navigate({ name: 'history' }); + +const showHistoryDetail = (id) => navigate({ name: 'test', id }); + +const goToQuestion = (index, options) => navigate({ name: 'quiz', index }, options); + +const startQuiz = async (config, amount) => { + showLoading('AI가 문제를 만들고 있어요'); + try { + const { results } = await api.questions(config, amount); + state.config = config; + state.questions = results.map((q) => ({ + ...q, + options: shuffle([q.correct_answer, ...q.incorrect_answers]), + })); + state.answers = []; + state.confidences = []; + state.record = null; + state.recordKey = null; + goToQuestion(0); + } catch (err) { + showStartScreen(err.message); + } +}; + +const finishQuiz = async () => { + const answers = state.questions.map((q, i) => ({ + question: q.question, + correct_answer: q.correct_answer, + incorrect_answers: q.incorrect_answers, + user_answer: state.answers[i] ?? null, + confidence: state.confidences[i] ?? null, + })); + + const key = JSON.stringify(answers.map((a) => [a.user_answer, a.confidence])); + if (state.record && state.recordKey === key) { + navigate({ name: 'test', id: state.record.id }); + return; + } + + showLoading('결과를 정리하고 있어요'); + try { + state.record = await api.saveTest({ ...state.config, questions: answers }); + state.recordKey = key; + navigate({ name: 'test', id: state.record.id }); + } catch (err) { + showError(err.message, () => goToQuestion(state.questions.length - 1, { replace: true })); + } +}; + +const showReport = (record) => { + const screen = document.createElement('div'); + screen.className = 'screen'; + screen.appendChild( + createTopbar([ + createButton('지난 기록', () => showHistoryScreen()), + createButton('새 시험', () => showStartScreen()), + ]) + ); + screen.appendChild(renderReport(record)); + showScreen(screen); +}; + +window.addEventListener('DOMContentLoaded', () => + renderRoute(history.state ?? parseLocation()) +); diff --git a/public/optionPage.js b/public/optionPage.js new file mode 100644 index 0000000..f533dc5 --- /dev/null +++ b/public/optionPage.js @@ -0,0 +1,144 @@ +const DIFFICULTIES = [ + { id: 'easy', label: '쉬움' }, + { id: 'medium', label: '보통' }, + { id: 'hard', label: '어려움' }, +]; + +const fillSelect = (select, values) => { + select.replaceChildren( + ...values.map((value) => { + const option = document.createElement('option'); + option.value = value; + option.textContent = value; + return option; + }) + ); + select.disabled = values.length === 0; +}; + +class OptionPage extends HTMLElement { + async connectedCallback() { + this.className = 'screen'; + this.innerHTML = ` +
+ + + CogMind + +

공부의 시작은 나를 아는것

+

문제를 풀면 AI가 여러분을 메타인지하고 피드백 해줘요.

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+ + +
+
+
+ + +
+ `; + + const errorBox = this.querySelector('#error'); + const showMessage = (message) => { + errorBox.className = message ? 'error-box' : ''; + errorBox.textContent = message ?? ''; + }; + if (this.dataset.error) showMessage(this.dataset.error); + + this.querySelector('#history').onclick = () => showHistoryScreen(); + + let difficulty = 'medium'; + this.querySelector('#difficulty').appendChild( + createChipGroup(DIFFICULTIES, { + selected: difficulty, + onSelect: (value) => { + difficulty = value; + }, + }) + ); + + const school = this.querySelector('#school'); + const subject = this.querySelector('#subject'); + const chapter = this.querySelector('#chapter'); + const student = this.querySelector('#student'); + const amount = this.querySelector('#amount'); + const startButton = this.querySelector('#start'); + + let chapters = {}; + const onSchoolChange = () => { + fillSelect(subject, Object.keys(chapters[school.value] ?? {})); + onSubjectChange(); + }; + const onSubjectChange = () => { + fillSelect(chapter, chapters[school.value]?.[subject.value] ?? []); + }; + school.onchange = onSchoolChange; + subject.onchange = onSubjectChange; + + startButton.disabled = true; + try { + chapters = await api.chapters(); + fillSelect(school, Object.keys(chapters)); + onSchoolChange(); + startButton.disabled = false; + } catch (err) { + showMessage(`단원 목록을 불러오지 못했습니다. ${err.message}`); + return; + } + + const submit = () => { + if (!chapter.value) { + showMessage('과목과 단원을 선택해 주세요.'); + return; + } + startQuiz( + { + student: student.value.trim() || '학생', + school: school.value, + subject: subject.value, + chapter: chapter.value, + difficulty, + }, + Math.min(Math.max(Number(amount.value) || 5, 1), 20) + ); + }; + + startButton.onclick = submit; + student.addEventListener('keydown', (event) => { + if (event.key === 'Enter') submit(); + }); + } +} + +customElements.define('option-page', OptionPage); diff --git a/public/report.js b/public/report.js new file mode 100644 index 0000000..0e7e762 --- /dev/null +++ b/public/report.js @@ -0,0 +1,336 @@ +const createAiButton = (label, container, request, onSuccess) => { + const button = createButton(label, async () => { + button.disabled = true; + button.textContent = '생성 중…'; + + const box = document.createElement('div'); + box.className = 'ai-box'; + box.textContent = 'AI가 답변을 작성하고 있어요…'; + container.appendChild(box); + + try { + const result = await request(); + box.remove(); + onSuccess(result); + } catch (err) { + box.classList.add('error'); + box.textContent = err.message; + } + button.textContent = label; + button.disabled = false; + }); + return button; +}; + +const renderTwin = (twin, order) => { + const box = document.createElement('div'); + box.className = 'twin'; + + const title = document.createElement('p'); + title.className = 'twin-title'; + title.textContent = `쌍둥이 문제 ${order}. ${twin.question}`; + box.appendChild(title); + + const options = document.createElement('ol'); + options.className = 'twin-options'; + shuffle([twin.correct_answer, ...twin.incorrect_answers]).forEach((option) => { + const item = document.createElement('li'); + item.textContent = option; + options.appendChild(item); + }); + box.appendChild(options); + + const answer = document.createElement('details'); + const summary = document.createElement('summary'); + summary.textContent = '정답 보기'; + const value = document.createElement('span'); + value.textContent = ` ${twin.correct_answer}`; + answer.append(summary, value); + box.appendChild(answer); + + return box; +}; + +const renderMessage = (role, content) => { + const bubble = document.createElement('div'); + bubble.className = `chat-message ${role}`; + bubble.textContent = content; + return bubble; +}; + +const renderChat = (record, question, index) => { + const chat = document.createElement('div'); + chat.className = 'chat'; + + const heading = document.createElement('p'); + heading.className = 'chat-heading'; + heading.textContent = '이어서 질문하기'; + chat.appendChild(heading); + + const log = document.createElement('div'); + log.className = 'chat-log'; + question.conversation.forEach((m) => log.appendChild(renderMessage(m.role, m.content))); + chat.appendChild(log); + + const form = document.createElement('form'); + form.className = 'chat-form'; + const input = document.createElement('textarea'); + input.rows = 1; + input.placeholder = '이 문제에 대해 더 궁금한 점을 물어보세요'; + input.setAttribute('aria-label', '후속 질문'); + const sendButton = document.createElement('button'); + sendButton.type = 'submit'; + sendButton.className = 'primary'; + sendButton.textContent = '질문'; + form.append(input, sendButton); + chat.appendChild(form); + + let pending = false; + + async function send(message) { + const text = message.trim(); + if (!text || pending) return; + + pending = true; + sendButton.disabled = true; + input.value = ''; + + log.appendChild(renderMessage('user', text)); + const answering = renderMessage('assistant', 'AI가 답변을 작성하고 있어요…'); + answering.classList.add('pending'); + log.appendChild(answering); + answering.scrollIntoView({ block: 'nearest' }); + + try { + const { messages } = await api.ask(record.id, index, text); + question.conversation.push(...messages); + answering.classList.remove('pending'); + answering.textContent = messages.find((m) => m.role === 'assistant')?.content ?? ''; + } catch (err) { + answering.classList.remove('pending'); + answering.classList.add('error'); + answering.textContent = err.message; + input.value = text; + } + + pending = false; + sendButton.disabled = false; + } + + form.onsubmit = (event) => { + event.preventDefault(); + send(input.value); + }; + input.onkeydown = (event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + send(input.value); + } + }; + + chat.focusInput = () => input.focus(); + return chat; +}; + +const renderFeedbackCard = (record) => { + const card = document.createElement('div'); + card.className = 'card feedback'; + + const heading = document.createElement('h2'); + heading.textContent = '메타인지 평가'; + card.appendChild(heading); + + const body = document.createElement('div'); + card.appendChild(body); + + const paint = (feedback) => { + body.replaceChildren(); + const summary = document.createElement('p'); + summary.className = 'feedback-summary'; + summary.textContent = feedback.summary; + + const detail = document.createElement('p'); + detail.className = 'feedback-detail'; + detail.textContent = feedback.detail; + + const recommendHeading = document.createElement('h3'); + recommendHeading.textContent = '학습 추천'; + const recommend = document.createElement('p'); + recommend.className = 'feedback-detail'; + recommend.textContent = feedback.recommendation; + + body.append(summary, detail, recommendHeading, recommend); + }; + + if (record.feedback) { + paint(record.feedback); + return card; + } + + body.innerHTML = `
+ AI가 이번 시험을 분석하고 있어요…
`; + + api.feedback(record.id) + .then(({ feedback }) => { + record.feedback = feedback; + paint(feedback); + }) + .catch((err) => { + body.replaceChildren(); + const error = document.createElement('p'); + error.className = 'error-box'; + error.textContent = err.message; + body.append( + error, + createAiButton('다시 시도', body, () => api.feedback(record.id), ({ feedback }) => { + record.feedback = feedback; + paint(feedback); + }) + ); + }); + + return card; +}; + +const renderQuestionCard = (record, question, index) => { + const item = document.createElement('div'); + item.className = 'card result-item'; + + const head = document.createElement('div'); + head.className = 'result-head'; + const badge = document.createElement('span'); + badge.className = `badge ${question.is_correct ? 'ok' : 'bad'}`; + badge.textContent = question.is_correct ? '정답' : '오답'; + const text = document.createElement('span'); + text.textContent = `${index + 1}. ${question.question}`; + head.append(badge, text); + item.appendChild(head); + + const answers = document.createElement('p'); + answers.className = 'answers'; + answers.innerHTML = + '내 답: · 정답:
내가 밝힌 확신도: '; + const spans = answers.querySelectorAll('span'); + spans[0].textContent = question.user_answer ?? '무응답'; + spans[1].textContent = question.correct_answer; + spans[2].textContent = CONFIDENCE_LABEL(question.confidence); + item.appendChild(answers); + + const detail = document.createElement('div'); + detail.className = 'result-detail'; + item.appendChild(detail); + + const explanationSection = document.createElement('div'); + explanationSection.className = 'explanation'; + detail.appendChild(explanationSection); + + question.conversation ??= []; + + const paintExplanation = (explanation) => { + const box = document.createElement('div'); + box.className = 'ai-box'; + box.textContent = explanation; + explanationSection.appendChild(box); + + const chat = renderChat(record, question, index); + if (!question.conversation.length) { + chat.hidden = true; + const askButton = createButton( + '이어서 질문하기', + () => { + chat.hidden = false; + askButton.remove(); + chat.focusInput(); + }, + 'ask-toggle' + ); + explanationSection.appendChild(askButton); + } + explanationSection.appendChild(chat); + }; + + let twinCount = 0; + const paintTwin = (twin) => { + twinCount += 1; + detail.appendChild(renderTwin(twin, twinCount)); + }; + + if (question.explanation) paintExplanation(question.explanation); + (question.children ?? []).forEach(paintTwin); + + const actions = document.createElement('div'); + actions.className = 'result-actions'; + + const explainButton = createAiButton( + 'AI 해설 보기', + explanationSection, + () => api.explain(record.id, index), + ({ explanation }) => { + question.explanation = explanation; + paintExplanation(explanation); + explainButton.remove(); + } + ); + if (!question.explanation) actions.appendChild(explainButton); + + actions.appendChild( + createAiButton( + '쌍둥이 문제 만들기', + detail, + () => api.twin(record.id, index), + ({ twin }) => { + question.children = [...(question.children ?? []), twin]; + paintTwin(twin); + } + ) + ); + + item.appendChild(actions); + return item; +}; + +const renderReport = (record) => { + const fragment = document.createDocumentFragment(); + const { score } = record; + + const summary = document.createElement('div'); + summary.className = 'card score'; + summary.innerHTML = ` +

+
+

+
+
+ 확신했지만 틀림 +
+
운으로 맞힘
+
확실히 아는 것
+
+

+ `; + summary.querySelector('.range').textContent = + `${record.student} · ${record.school} ${record.subject} · ${record.chapter} · ` + + `난이도 ${DIFFICULTY_LABEL[record.difficulty] ?? record.difficulty} · ${formatDate(record.createdAt)}`; + summary.querySelector('.big').textContent = `${score.percent}점`; + summary.querySelector('.count').textContent = + `${score.total}문제 중 ${score.correct}문제 정답`; + const tiles = summary.querySelectorAll('.insight strong'); + tiles[0].textContent = score.overconfident; + tiles[1].textContent = score.lucky; + tiles[2].textContent = score.solid; + summary.querySelector('.note').textContent = score.overconfident + ? '확신했지만 틀린 문제가 메타인지의 빈틈입니다. 이 개념부터 다시 확인해 보세요.' + : '안다고 착각한 문제가 없습니다. 자신의 이해도를 잘 파악하고 있어요.'; + fragment.appendChild(summary); + + fragment.appendChild(renderFeedbackCard(record)); + + const list = document.createElement('div'); + list.className = 'result-list'; + record.questions.forEach((question, index) => + list.appendChild(renderQuestionCard(record, question, index)) + ); + fragment.appendChild(list); + + return fragment; +}; diff --git a/public/testPage.js b/public/testPage.js new file mode 100644 index 0000000..f1c0cef --- /dev/null +++ b/public/testPage.js @@ -0,0 +1,136 @@ +class TestPage extends HTMLElement { + connectedCallback() { + this.className = 'screen'; + this.renderQuestion(); + } + + renderHeader(step) { + const wrapper = document.createElement('div'); + const counter = document.createElement('span'); + counter.className = 'muted'; + counter.textContent = `${step} / ${state.questions.length}`; + + const topbar = createTopbar([counter]); + topbar.querySelector('.brand').onclick = () => { + if (confirm('처음 화면으로 돌아갈까요? 지금까지의 응답은 사라집니다.')) { + showStartScreen(); + } + }; + + const track = document.createElement('div'); + track.className = 'progress-track'; + const fill = document.createElement('div'); + fill.className = 'progress-fill'; + fill.style.width = `${(step / state.questions.length) * 100}%`; + track.appendChild(fill); + + wrapper.append(topbar, track); + return wrapper; + } + + renderQuestion() { + const index = state.current; + const question = state.questions[index]; + let selected = state.answers[index] ?? null; + let confidence = state.confidences[index] ?? null; + + this.replaceChildren(this.renderHeader(index + 1)); + + const card = document.createElement('div'); + card.className = 'card'; + + const title = document.createElement('p'); + title.className = 'question-text'; + title.textContent = `${index + 1}. ${question.question}`; + card.appendChild(title); + + const list = document.createElement('ul'); + list.className = 'options'; + const optionButtons = question.options.map((option, i) => { + const item = document.createElement('li'); + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'option'; + button.setAttribute('aria-pressed', String(option === selected)); + + const key = document.createElement('span'); + key.className = 'key'; + key.textContent = String.fromCharCode(65 + i); + const text = document.createElement('span'); + text.textContent = option; + + button.append(key, text); + button.onclick = () => { + selected = option; + optionButtons.forEach((b) => b.setAttribute('aria-pressed', 'false')); + button.setAttribute('aria-pressed', 'true'); + updateSubmitState(); + }; + + item.appendChild(button); + list.appendChild(item); + return button; + }); + card.appendChild(list); + + const confidenceBox = document.createElement('div'); + confidenceBox.className = 'confidence'; + const confidenceLabel = document.createElement('label'); + confidenceLabel.textContent = '이 답에 얼마나 확신하나요?'; + confidenceBox.append( + confidenceLabel, + createChipGroup(CONFIDENCE, { + selected: confidence, + onSelect: (value) => { + confidence = value; + updateSubmitState(); + }, + }) + ); + card.appendChild(confidenceBox); + + const actions = document.createElement('div'); + actions.className = 'actions'; + if (index > 0) { + actions.appendChild(createButton('이전 문제', () => history.back(), 'ghost')); + } + + const isLast = index === state.questions.length - 1; + const submitButton = document.createElement('button'); + submitButton.className = 'primary'; + submitButton.textContent = isLast ? '결과 보기' : '다음 문제'; + submitButton.onclick = () => { + state.answers[index] = selected; + state.confidences[index] = confidence; + if (isLast) { + finishQuiz(); + } else { + goToQuestion(index + 1); + } + }; + actions.appendChild(submitButton); + card.appendChild(actions); + + const updateSubmitState = () => { + submitButton.disabled = !(selected && confidence); + }; + updateSubmitState(); + + this.appendChild(card); + + this.onkeydown = (event) => { + if (event.target.tagName === 'INPUT') return; + const fromLetter = event.key.toUpperCase().charCodeAt(0) - 65; + const fromNumber = Number(event.key) - 1; + const at = optionButtons[fromLetter] ?? optionButtons[fromNumber]; + if (event.key.length === 1 && at) { + at.click(); + at.focus(); + } + }; + this.tabIndex = -1; + this.focus({ preventScroll: true }); + } +} + +customElements.define('test-page', TestPage); diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..29692c8 --- /dev/null +++ b/server.ts @@ -0,0 +1,420 @@ +import express from 'express'; +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { GoogleGenAI, Type } from '@google/genai'; +import * as store from './store.ts'; +import type { TestQuestion, TestRecord } from './store.ts'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const API_KEY = process.env.GEMINI_API_KEY; +if (!API_KEY) { + console.error( + 'GEMINI_API_KEY 가 설정되지 않았습니다. LG/.env 파일에 GEMINI_API_KEY=... 를 추가하세요.\n' + + '키 발급: https://aistudio.google.com/apikey' + ); + process.exit(1); +} + +const ai = new GoogleGenAI({ apiKey: API_KEY }); +const MODEL = process.env.GEMINI_MODEL ?? 'gemini-3.5-flash-lite'; + +const chapters: Record> = JSON.parse( + await fs.readFile(path.join(__dirname, 'chapter.json'), 'utf8') +); +await store.load(); + +const app = express(); +app.use(express.json({ limit: '1mb' })); +app.use('/public', express.static(path.join(__dirname, 'public'))); + +app.get('/', (_req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +async function generate(options: { + prompt?: string; + contents?: unknown; + systemInstruction: string; + schema?: object; + maxOutputTokens?: number; + temperature?: number; +}) { + const response = await ai.models.generateContent({ + model: MODEL, + contents: (options.contents ?? options.prompt) as never, + config: { + systemInstruction: options.systemInstruction, + temperature: options.temperature ?? 0.8, + maxOutputTokens: options.maxOutputTokens ?? 2048, + ...(options.schema + ? { responseMimeType: 'application/json', responseSchema: options.schema } + : {}), + }, + }); + + const text = response.text?.trim(); + if (!text) { + const reason = response.candidates?.[0]?.finishReason ?? 'UNKNOWN'; + console.error('모델이 빈 응답을 반환했습니다.', reason, response.usageMetadata); + throw new HttpError(502, `AI가 빈 응답을 보냈습니다 (${reason}). 다시 시도해 주세요.`); + } + return text; +} + +async function generateJson(options: Parameters[0] & { prompt: string; schema: object }) { + return JSON.parse(await generate(options)) as T; +} + +const route = + (handler: (req: express.Request) => Promise) => + async (req: express.Request, res: express.Response) => { + try { + res.json(await handler(req)); + } catch (err) { + if (err instanceof HttpError) { + res.status(err.status).json({ error: err.message }); + return; + } + console.error(err); + res.status(502).json({ + error: 'AI 응답을 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.', + }); + } + }; + +class HttpError extends Error { + constructor(readonly status: number, message: string) { + super(message); + } +} + +const QUESTION_PROPERTIES = { + question: { type: Type.STRING }, + correct_answer: { type: Type.STRING }, + incorrect_answers: { type: Type.ARRAY, items: { type: Type.STRING } }, +}; + +const QUESTIONS_SCHEMA = { + type: Type.OBJECT, + properties: { + results: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: QUESTION_PROPERTIES, + required: ['question', 'correct_answer', 'incorrect_answers'], + }, + }, + }, + required: ['results'], +}; + +const TWIN_SCHEMA = { + type: Type.OBJECT, + properties: QUESTION_PROPERTIES, + required: ['question', 'correct_answer', 'incorrect_answers'], +}; + +const FEEDBACK_SCHEMA = { + type: Type.OBJECT, + properties: { + summary: { type: Type.STRING }, + detail: { type: Type.STRING }, + recommendation: { type: Type.STRING }, + }, + required: ['summary', 'detail', 'recommendation'], +}; + +const DIFFICULTY_KO: Record = { + easy: '쉬움 (기본 개념 확인)', + medium: '보통 (개념 응용)', + hard: '어려움 (여러 개념의 종합 및 분석)', +}; + +interface GeneratedQuestion { + question: string; + correct_answer: string; + incorrect_answers: string[]; +} + +const isUsable = (q: GeneratedQuestion) => + Boolean(q?.question && q?.correct_answer && q?.incorrect_answers?.length); + +app.get('/api/chapters', (_req, res) => res.json(chapters)); + +app.post( + '/api/questions', + route(async (req) => { + const school = String(req.body?.school ?? ''); + const subject = String(req.body?.subject ?? ''); + const chapter = String(req.body?.chapter ?? ''); + if (!chapters[school]?.[subject]?.includes(chapter)) { + throw new HttpError(400, '학교 종류·과목·단원을 다시 선택해 주세요.'); + } + const difficulty = String(req.body?.difficulty ?? 'medium'); + const amount = Math.min(Math.max(Number(req.body?.amount) || 5, 1), 20); + + const { results = [] } = await generateJson<{ results?: GeneratedQuestion[] }>({ + systemInstruction: + '당신은 학생의 메타인지 학습을 돕는 한국 중·고등학교 교사입니다. ' + + '교육과정에 맞는 정확한 4지선다 객관식 문제를 출제합니다. ' + + '정답은 반드시 하나이며, 오답도 그럴듯해야 합니다. 모든 텍스트는 한국어로 작성합니다.', + prompt: + `학교: ${school}\n과목: ${subject}\n단원: ${chapter}\n` + + `난이도: ${DIFFICULTY_KO[difficulty] ?? DIFFICULTY_KO.medium}\n` + + `문항 수: ${amount}\n\n` + + '위 조건으로 서로 겹치지 않는 4지선다 문제를 만들어 주세요. ' + + '단원 안에서도 세부 개념이 골고루 나오게 하고, ' + + 'incorrect_answers 에는 오답 3개만 넣고, 보기 앞에 번호나 기호를 붙이지 마세요.', + schema: QUESTIONS_SCHEMA, + temperature: 1.0, + maxOutputTokens: 8192, + }); + + const usable = results.filter(isUsable).slice(0, amount); + if (!usable.length) throw new Error('빈 문항 응답'); + return { results: usable }; + }) +); + +app.post( + '/api/tests', + route(async (req) => { + const body = req.body ?? {}; + const questions: TestQuestion[] = (body.questions ?? []).map((q: any) => ({ + question: String(q.question ?? ''), + correct_answer: String(q.correct_answer ?? ''), + incorrect_answers: (q.incorrect_answers ?? []).map(String), + user_answer: q.user_answer ?? null, + confidence: q.confidence ?? null, + is_correct: q.user_answer === q.correct_answer, + explanation: null, + conversation: [], + children: [], + })); + + if (!questions.length) throw new HttpError(400, '저장할 문항이 없습니다.'); + + const correct = questions.filter((q) => q.is_correct).length; + const record: TestRecord = { + id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + createdAt: new Date().toISOString(), + student: String(body.student || '학생').slice(0, 30), + school: String(body.school ?? ''), + subject: String(body.subject ?? ''), + chapter: String(body.chapter ?? ''), + difficulty: String(body.difficulty ?? 'medium'), + score: { + total: questions.length, + correct, + percent: Math.round((correct / questions.length) * 100), + overconfident: questions.filter((q) => q.confidence === 'sure' && !q.is_correct) + .length, + lucky: questions.filter((q) => q.confidence !== 'sure' && q.is_correct).length, + solid: questions.filter((q) => q.confidence === 'sure' && q.is_correct).length, + }, + feedback: null, + questions, + }; + + await store.save(record); + return record; + }) +); + +app.get('/api/tests', (_req, res) => res.json({ tests: store.listSummaries() })); + +app.get('/api/tests/:id', (req, res) => { + const record = store.get(req.params.id); + if (!record) { + res.status(404).json({ error: '기록을 찾을 수 없습니다.' }); + return; + } + res.json(record); +}); + +function findQuestion(req: express.Request) { + const record = store.get(String(req.params.id)); + const index = Number(req.params.index); + const question = record?.questions[index]; + if (!record || !question) throw new HttpError(404, '문항을 찾을 수 없습니다.'); + return { record, index, question }; +} + +app.post( + '/api/tests/:id/questions/:index/explain', + route(async (req) => { + const { record, index, question } = findQuestion(req); + if (question.explanation) return { explanation: question.explanation }; + + const explanation = await generate({ + systemInstruction: + 'CogMind 는 학생의 메타인지를 돕는 피드백 도우미입니다. 한국어 존댓말로, 250자 내외로 답합니다.\n' + + '1) 정답의 근거를 한 문장으로 설명합니다.\n' + + '2) 틀렸다면 학생이 어디서 잘못 생각했는지 짚어 줍니다.\n' + + '3) 확신했는데 틀렸다면 그 개념을 다시 확인해야 한다고 분명히 말합니다. ' + + '찍어서 맞혔다면 아직 아는 것이 아니라고 알려 줍니다.\n' + + '마크다운 기호 없이 자연스러운 문단으로 작성하세요.', + prompt: + `단원: ${record.subject} · ${record.chapter}\n` + + `문제: ${question.question}\n` + + `정답: ${question.correct_answer}\n` + + `학생의 답: ${question.user_answer ?? '(무응답)'} (${ + question.is_correct ? '정답' : '오답' + })\n` + + `학생이 밝힌 확신도: ${CONFIDENCE_KO[question.confidence ?? ''] ?? '미응답'}`, + temperature: 0.6, + maxOutputTokens: 1024, + }); + + await store.setExplanation(record.id, index, explanation); + return { explanation }; + }) +); + +const CONFIDENCE_KO: Record = { + sure: '확실해요', + unsure: '애매해요', + guess: '찍었어요', +}; + +const CHAT_WINDOW = 20; + +app.post( + '/api/tests/:id/questions/:index/ask', + route(async (req) => { + const { record, index, question } = findQuestion(req); + if (!question.explanation) throw new HttpError(400, '먼저 AI 해설을 생성해 주세요.'); + + const message = String(req.body?.message ?? '').trim().slice(0, 1000); + if (!message) throw new HttpError(400, '질문을 입력해 주세요.'); + + const contents = [ + ...question.conversation.slice(-CHAT_WINDOW).map((m) => ({ + role: m.role === 'assistant' ? 'model' : 'user', + parts: [{ text: m.content }], + })), + { role: 'user', parts: [{ text: message }] }, + ]; + + const answer = await generate({ + systemInstruction: + 'CogMind 는 학생이 방금 푼 문제의 해설에 이어서 질문할 수 있는 1:1 튜터입니다. ' + + '한국어 존댓말로, 400자 안쪽으로 답합니다. ' + + '학생이 스스로 이해하도록 개념을 짚어 주고, 필요하면 예시를 듭니다. ' + + '문제와 관계없는 질문에는 이 문제와 관련해 도울 수 있다고 안내하세요. ' + + '모르는 것은 아는 척하지 말고 모른다고 말합니다. ' + + '마크다운 기호 없이 자연스러운 문단으로 작성하세요.\n\n' + + '--- 학생이 푼 문제 ---\n' + + `단원: ${record.school} ${record.subject} · ${record.chapter}\n` + + `문제: ${question.question}\n` + + `보기: ${[question.correct_answer, ...question.incorrect_answers].join(', ')}\n` + + `정답: ${question.correct_answer}\n` + + `학생의 답: ${question.user_answer ?? '(무응답)'} (${ + question.is_correct ? '정답' : '오답' + })\n` + + `학생이 밝힌 확신도: ${CONFIDENCE_KO[question.confidence ?? ''] ?? '미응답'}\n` + + `학생이 읽은 해설: ${question.explanation}\n`, + contents, + temperature: 0.6, + maxOutputTokens: 4096, + }); + + const now = new Date().toISOString(); + const messages = [ + { role: 'user' as const, content: message, createdAt: now }, + { role: 'assistant' as const, content: answer, createdAt: new Date().toISOString() }, + ]; + await store.addMessages(record.id, index, messages); + return { messages }; + }) +); + +app.post( + '/api/tests/:id/questions/:index/twin', + route(async (req) => { + const { record, index, question } = findQuestion(req); + + const twin = await generateJson({ + systemInstruction: + '당신은 쌍둥이 문제를 만드는 교사입니다. 원본 문제와 같은 개념·난이도를 묻되, ' + + '소재와 숫자는 다른 새 4지선다 문제를 하나 만듭니다. ' + + 'incorrect_answers 에는 오답 3개만 넣고, 보기 앞에 번호나 기호를 붙이지 마세요. ' + + '모든 텍스트는 한국어로 작성합니다.', + prompt: + `단원: ${record.subject} · ${record.chapter}\n` + + `원본 문제: ${question.question}\n원본 정답: ${question.correct_answer}\n` + + `이미 만든 쌍둥이 문제 ${record.questions[index]!.children.length}개와도 겹치지 않게 해주세요.`, + schema: TWIN_SCHEMA, + temperature: 1.0, + maxOutputTokens: 2048, + }); + + if (!isUsable(twin)) throw new Error('빈 쌍둥이 문제 응답'); + + const child = { ...twin, createdAt: new Date().toISOString() }; + await store.addChild(record.id, index, child); + return { twin: child }; + }) +); + +app.post( + '/api/tests/:id/feedback', + route(async (req) => { + const record = store.get(String(req.params.id)); + if (!record) throw new HttpError(404, '기록을 찾을 수 없습니다.'); + if (record.feedback) return { feedback: record.feedback }; + + const lines = record.questions + .map( + (q, i) => + `${i + 1}. ${q.question}\n` + + ` 정답: ${q.correct_answer} / 학생의 답: ${q.user_answer ?? '무응답'} ` + + `(${q.is_correct ? 'O' : 'X'}, 확신도: ${CONFIDENCE_KO[q.confidence ?? ''] ?? '미응답'})` + ) + .join('\n'); + + const feedback = await generateJson({ + systemInstruction: + 'CogMind 는 학생의 메타인지를 진단하는 학습 코치입니다. 한국어 존댓말로 작성합니다.\n' + + 'summary: 학생 이름을 넣어 어느 개념이 부족한지 한 문장으로 진단합니다. ' + + '예) "OO학생은 미분 부분에 부족함이 있습니다."\n' + + 'detail: 무엇은 되고 무엇이 안 되는지 근거를 들어 2~3문장으로 씁니다. ' + + '특히 확신했는데 틀린 문항은 "안다고 착각한 개념"으로 분명히 짚어 줍니다. ' + + '찍어서 맞힌 문항은 아직 아는 것이 아니라고 알려 줍니다.\n' + + 'recommendation: 구체적인 교재·단원·학습 방법을 제시하고, ' + + '마지막에 CogMind 에서 맞춤형 문제를 더 풀어 보라고 권합니다.\n' + + '마크다운 기호 없이 자연스러운 문장으로 작성하세요.', + prompt: + `학생 이름: ${record.student}\n` + + `범위: ${record.school} ${record.subject} · ${record.chapter} (난이도 ${ + DIFFICULTY_KO[record.difficulty] ?? record.difficulty + })\n` + + `점수: ${record.score.total}문제 중 ${record.score.correct}문제 정답 (${record.score.percent}점)\n` + + `확신했지만 틀림 ${record.score.overconfident}문제 / 운으로 맞힘 ${record.score.lucky}문제 / ` + + `확실히 아는 것 ${record.score.solid}문제\n\n` + + `문항별 결과:\n${lines}`, + schema: FEEDBACK_SCHEMA, + temperature: 0.7, + maxOutputTokens: 2048, + }); + + await store.setFeedback(record.id, feedback); + return { feedback }; + }) +); + +app.get('*', (req, res) => { + if (req.path.startsWith('/api/') || req.path.startsWith('/public/')) { + res.status(404).json({ error: '찾을 수 없습니다.' }); + return; + } + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +const PORT = Number(process.env.PORT) || 1234; +app.listen(PORT, () => { + console.log(`CogMind 실행 중 → http://127.0.0.1:${PORT} (model: ${MODEL})`); +}); diff --git a/store.ts b/store.ts new file mode 100644 index 0000000..074045c --- /dev/null +++ b/store.ts @@ -0,0 +1,143 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +export interface TwinQuestion { + question: string; + correct_answer: string; + incorrect_answers: string[]; + createdAt: string; +} + +export interface ChatMessage { + role: 'user' | 'assistant'; + content: string; + createdAt: string; +} + +export interface TestQuestion { + question: string; + correct_answer: string; + incorrect_answers: string[]; + user_answer: string | null; + confidence: string | null; + is_correct: boolean; + explanation: string | null; + conversation: ChatMessage[]; + children: TwinQuestion[]; +} + +export interface TestRecord { + id: string; + createdAt: string; + student: string; + school: string; + subject: string; + chapter: string; + difficulty: string; + score: { + total: number; + correct: number; + percent: number; + overconfident: number; + lucky: number; + solid: number; + }; + feedback: { summary: string; detail: string; recommendation: string } | null; + questions: TestQuestion[]; +} + +const DATA_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'data'); +const FILE = path.join(DATA_DIR, 'history.json'); + +let records: TestRecord[] = []; +let writeQueue: Promise = Promise.resolve(); + +export async function load() { + try { + records = JSON.parse(await fs.readFile(FILE, 'utf8')); + } catch { + records = []; + } + for (const record of records) { + for (const question of record.questions) { + question.conversation ??= []; + question.children ??= []; + } + } +} + +function persist() { + writeQueue = writeQueue.then(async () => { + await fs.mkdir(DATA_DIR, { recursive: true }); + await fs.writeFile(FILE, JSON.stringify(records, null, 2), 'utf8'); + }); + return writeQueue; +} + +export function listSummaries() { + return records + .map((r) => ({ + id: r.id, + createdAt: r.createdAt, + student: r.student, + school: r.school, + subject: r.subject, + chapter: r.chapter, + difficulty: r.difficulty, + score: r.score, + twinCount: r.questions.reduce((n, q) => n + q.children.length, 0), + explainedCount: r.questions.filter((q) => q.explanation).length, + askedCount: r.questions.reduce( + (n, q) => n + q.conversation.filter((m) => m.role === 'user').length, + 0 + ), + })) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + +export function get(id: string) { + return records.find((r) => r.id === id) ?? null; +} + +export async function save(record: TestRecord) { + records.push(record); + await persist(); + return record; +} + +async function updateQuestion( + testId: string, + index: number, + mutate: (question: TestQuestion) => void +) { + const record = get(testId); + const question = record?.questions[index]; + if (!question) return null; + mutate(question); + await persist(); + return question; +} + +export const setExplanation = (testId: string, index: number, explanation: string) => + updateQuestion(testId, index, (q) => { + q.explanation = explanation; + }); + +export const addChild = (testId: string, index: number, twin: TwinQuestion) => + updateQuestion(testId, index, (q) => { + q.children.push(twin); + }); + +export const addMessages = (testId: string, index: number, messages: ChatMessage[]) => + updateQuestion(testId, index, (q) => { + q.conversation.push(...messages); + }); + +export async function setFeedback(testId: string, feedback: TestRecord['feedback']) { + const record = get(testId); + if (!record) return null; + record.feedback = feedback; + await persist(); + return record; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ef0c14a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + /* Linting */ + "skipLibCheck": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["server.ts"] +}