From 02d9d0785bb669eada53afe142b39c8e5c2806d6 Mon Sep 17 00:00:00 2001 From: seung6lee Date: Mon, 10 Aug 2026 21:35:17 +0900 Subject: [PATCH] first commit --- .env.example | 8 + .gitignore | 178 ++++++ Metacognitive ability.md | 52 ++ README.md | 98 ++++ bun.lockb | Bin 0 -> 57445 bytes chapter.json | 52 ++ mise.toml | 2 + package-lock.json | 1063 ++++++++++++++++++++++++++++++++++ package.json | 24 + public/historyPage.js | 125 ++++ public/images/favicon.png | Bin 0 -> 5899 bytes public/images/logo-dark.png | Bin 0 -> 8612 bytes public/images/logo-light.png | Bin 0 -> 13079 bytes public/index.html | 25 + public/main.css | 728 +++++++++++++++++++++++ public/main.js | 296 ++++++++++ public/optionPage.js | 144 +++++ public/report.js | 336 +++++++++++ public/testPage.js | 136 +++++ server.ts | 420 ++++++++++++++ store.ts | 143 +++++ tsconfig.json | 23 + 22 files changed, 3853 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Metacognitive ability.md create mode 100644 README.md create mode 100755 bun.lockb create mode 100644 chapter.json create mode 100644 mise.toml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/historyPage.js create mode 100644 public/images/favicon.png create mode 100644 public/images/logo-dark.png create mode 100644 public/images/logo-light.png create mode 100644 public/index.html create mode 100644 public/main.css create mode 100644 public/main.js create mode 100644 public/optionPage.js create mode 100644 public/report.js create mode 100644 public/testPage.js create mode 100644 server.ts create mode 100644 store.ts create mode 100644 tsconfig.json 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 0000000000000000000000000000000000000000..696a27df0a5ae1c981db97f3b74270e283921837 GIT binary patch literal 57445 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7a0460f0c54540f1c154875f5aa061d64dc12243 GIT binary patch literal 5899 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 KAzvqJ9{hiIuuotB literal 0 HcmV?d00001 diff --git a/public/images/logo-dark.png b/public/images/logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..3ea2f7211b1eb1c631123370076eca59111fb0d4 GIT binary patch literal 8612 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 vQl0ymJq9xY>|IqD?elQ|lMhVqiP|e + + + + + + + + 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"] +}