first commit
This commit is contained in:
@@ -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
|
||||
+178
@@ -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/
|
||||
@@ -0,0 +1,52 @@
|
||||
|
||||
# 메타인지란
|
||||
MetaCognitive ability(메타인지)란 meta(초월)와 cognitive(인지)의 합성어로 인지에 대한 인지를 하는 능력으로 1976년, 미국의 심리학자 존 플래벌(John Flavell)에 의해 처음 도입되었다. 이를 쉽게 풀어말하자면, 내가하는 생각에 대해서 생각하는 것이다.
|
||||
|
||||
이는 크게 **메타인지 지식**, **메타인지 조절** 로 나뉜다.
|
||||
## 메타인지 지식
|
||||
메타인지 지식은 개인이 자신의 인지 과정을 얼마나 잘 파악하고 있는지 나타낸다. 이를 통해 자신의 학습 과정과 방법을 더 잘 이해할 수 있다. 이는 더 세분화되어 **선언적 지식**, **절차적 지식**, **조건적 지식** 유형으로 나뉜다.
|
||||
### 선언전 지식
|
||||
선언적 지식은 자신이 학습하는 내용에서 어떤 개념, 사실, 정보를 아는지에 대한 지식이다.
|
||||
### 절차적 지식
|
||||
절차적 지식이란 특정 작업의 순서나 절차를 새우고, 알고리즘을 만드는 능력이다.
|
||||
### 조건적 지식
|
||||
조건적 지식이란 상황에 알맞은 지식이나 절차를 선택하는 능력이다.
|
||||
|
||||
## 메타인지 조절
|
||||
메타인지 조절은 자신의 학습 상황을 분석하는 능력이다. 이를 통해서는 자신의 학습 태도, 상황들에 대한 이해도를 높일 수 있으며 학습 과정을 효과적으로 개선할 수 있다. 이 또한 **계획**, **모니터링**, **평가** 로 나뉜다.
|
||||
### 계획
|
||||
학습이나, 문제 풀이를 시작하기전 어떻게 진행할지 구상하는 능력이다.
|
||||
### 모니터링
|
||||
학습을 진행하는 동안 자신의 상태를 감시하고 분석하는 능력이다.
|
||||
### 평가
|
||||
학습이 끝난 후, 그 결과를 평가하는 능력이다.
|
||||
|
||||
___
|
||||
|
||||
# 메타인지의 활용
|
||||
메타인지는 그 자체로 인간의 정신적 발전에 굉장히 뛰어난 개념이기에 수많은 사람들이 활용활 수 있다. 그중에서도 메타인지는 많은 학습을 해야하는 학생들에게 뛰어난 활용성을 지닌다. 학생은 #메타인지 지식을 통해 자신의 현재 학습 성취도를 분석할 수 있으며, #메타인지 조절을 통해 자신의 공부 방법을 조절할 수 있다.
|
||||
|
||||
그럼, 학습에서의 메타인지 적용 단계에 대해 더 자세히 설명해보겠다.
|
||||
|
||||
먼저 메타인지 지식은 학습을 하는 동안 이용된다.
|
||||
1. 선언적 지식 : 개념을 공부할 때 해당 개념에 대한 이해도를 평가한다.
|
||||
2. 절차적 지식 : 문제 풀이를 할 때, 정확한 절차를 이용하는지 평가한다.
|
||||
3. 조건적 지식 : 시험에서 각 문제마다 올바른 절차를 세우는데 이용된다.
|
||||
|
||||
메타인지 조절은 학습을 보다 더 효율적으로 할 수 있게 해준다.
|
||||
1. 계획 : 지난 학습을 바탕으로 나에게 부족한 부분을 보충할 수 있게 계획을 세운다.
|
||||
2. 모니터링 : 메타인지 지식을 활용하면서 학습을 진행하며, 학습 도중 내가 메타인지 지식을 잘 활용하고 있는지 확인한다.
|
||||
3. 평가 : 계획과 일치하는 결과가 도출되었고, 모니터링 결과가 우수한지 확인한다. 이를 바탕으로 다음 학습에 더욱더 발전된 계획을 세울 수 있다.
|
||||
|
||||
___
|
||||
|
||||
# 우리 서비스에 적용
|
||||
위와 같이 학습을 할 때, 메타인지를 잘 활용한다면 좋은 결과를 얻어 공부의 효율성을 향상시킬 수 있으면을 수 있겠지만, 메타인지가 부족한 경우에는 반대의 결과가 도출된다.
|
||||
|
||||
따라서, 우리는 메타인지 능력이 부족한 학생들에게 메타인지 과정을 도와주는 서비스 CogMind를 구상하였다. CogMind는 온라인 서비스이기 때문에 조건적 지식, 모니터링을 제외한 나머지 메타인지 유형을 바탕으로 구상하였다. 그리하여, 서비스에는 크게 테스트 파트|테스트 파트(메타인지 지식)과 피드백 파트|피드백 및 계획을 제공해주는 파트(메타인지 조절)로 나눠진다.
|
||||
|
||||
## 테스트 파트
|
||||
이 파트는 학생들의 학업 성취도를 평가하기 위한 테스트를 생성하는 과정이다. 이 때, 테스트는 다양한 개념의 문제들을 포함하여, 학생의 종합적인 성취도를 확인할 수 있어야 하며, 각각의 개념에 대해 정확하게 아는지 평가하기 위한 문제선정 알고리즘이 필요하며 이를 도와주기 위해 인공지능이 필요하다.
|
||||
|
||||
## 피드백 파트
|
||||
위의 테스트를 통한 결과를 분석하고 분석을 기반으로 학생의 학업 성취에 대한 피드백과 분석 결과를 제공한다. 이때, 정확하고 명료한 피드백을 제공하기 위해서 피드백에 대한 설문과 학습이 필요하다.
|
||||
@@ -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 시작 화면 (<option-page>)
|
||||
public/testPage.js 퀴즈 화면 (<test-page>)
|
||||
public/historyPage.js 지난 기록 목록 (<history-page>)
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"고등학교": {
|
||||
"수학": [
|
||||
"다항식",
|
||||
"방정식과 부등식",
|
||||
"도형의 방정식",
|
||||
"집합과 명제",
|
||||
"함수",
|
||||
"경우의 수",
|
||||
"지수함수와 로그",
|
||||
"삼각함수",
|
||||
"수열",
|
||||
"함수의 극한과 연속",
|
||||
"다항항수의 미분법",
|
||||
"다항항수의 적분법",
|
||||
"급수",
|
||||
"도함수",
|
||||
"행렬"
|
||||
],
|
||||
"과학": [
|
||||
"힘과 운동",
|
||||
"에너지와 열",
|
||||
"시간과 공간",
|
||||
"물질의 전기적 특성",
|
||||
"물질의 자기적 특성"
|
||||
]
|
||||
},
|
||||
"중학교": {
|
||||
"수학": [
|
||||
"소인수분해",
|
||||
"정수와 유리수",
|
||||
"방정식",
|
||||
"그래프와 비례",
|
||||
"기본도형",
|
||||
"평면도형",
|
||||
"입체도형",
|
||||
"통계",
|
||||
"수와 식",
|
||||
"부등식",
|
||||
"방정식",
|
||||
"함수",
|
||||
"도형의 성질",
|
||||
"도형의 닮음",
|
||||
"피타고라스 정리",
|
||||
"확률",
|
||||
"제곱근과 실수",
|
||||
"다항식의 곱셈과 인수분해",
|
||||
"이차방정식",
|
||||
"이차함수"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+1063
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 = `<div class="inline-loading"><span class="spinner small"></span>
|
||||
<span>기록을 불러오는 중이에요…</span></div>`;
|
||||
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 = `<p>아직 저장된 기록이 없습니다.</p>`;
|
||||
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 = `
|
||||
<div class="insight">
|
||||
<div><strong></strong>본 시험</div>
|
||||
<div><strong></strong>푼 문제</div>
|
||||
<div><strong></strong>평균 정답률</div>
|
||||
<div class="${totals.overconfident ? 'danger' : ''}">
|
||||
<strong></strong>확신했지만 틀림
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="CogMind — 문제를 풀고 확신도를 기록하면 AI가 메타인지 상태를 분석해 주는 학습 서비스">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>CogMind - 메타인지 학습 진단</title>
|
||||
<link rel="icon" type="image/png" href="/public/images/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/public/images/favicon.png">
|
||||
<link rel="stylesheet" href="/public/main.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
|
||||
<script src="/public/main.js"></script>
|
||||
<script src="/public/report.js"></script>
|
||||
<script src="/public/optionPage.js"></script>
|
||||
<script src="/public/testPage.js"></script>
|
||||
<script src="/public/historyPage.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+728
@@ -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;
|
||||
}
|
||||
+296
@@ -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 = `<div class="spinner"></div><p class="dots"></p>`;
|
||||
box.querySelector('p').textContent = message;
|
||||
showScreen(box);
|
||||
};
|
||||
|
||||
const showError = (message, onBack) => {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'screen';
|
||||
box.innerHTML = `<div class="card"><p class="error-box"></p></div>`;
|
||||
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())
|
||||
);
|
||||
@@ -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 = `
|
||||
<div class="hero">
|
||||
<picture>
|
||||
<source srcset="/public/images/logo-dark.png"
|
||||
media="(prefers-color-scheme: dark)">
|
||||
<img src="/public/images/logo-light.png" alt="CogMind">
|
||||
</picture>
|
||||
<h1>공부의 시작은 나를 아는것</h1>
|
||||
<p>문제를 풀면 AI가 여러분을 메타인지하고 피드백 해줘요.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label for="student">이름</label>
|
||||
<input id="student" type="text" placeholder="예: 이승준" autocomplete="off"
|
||||
maxlength="30">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="school">학교 종류</label>
|
||||
<select id="school"></select>
|
||||
</div>
|
||||
<div class="row" style="margin-top:16px">
|
||||
<div class="field" style="margin:0">
|
||||
<label for="subject">과목</label>
|
||||
<select id="subject"></select>
|
||||
</div>
|
||||
<div class="field" style="margin:0">
|
||||
<label for="chapter">단원</label>
|
||||
<select id="chapter"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top:16px">
|
||||
<div class="field" style="margin:0">
|
||||
<label>난이도</label>
|
||||
<div id="difficulty"></div>
|
||||
</div>
|
||||
<div class="field" style="margin:0">
|
||||
<label for="amount">문항 수 (1–20)</label>
|
||||
<input id="amount" type="number" min="1" max="20" value="5">
|
||||
</div>
|
||||
</div>
|
||||
<div id="error"></div>
|
||||
<button class="primary block" id="start" style="margin-top:24px">
|
||||
학습 진단 시작하기
|
||||
</button>
|
||||
<button class="block" id="history" style="margin-top:10px">
|
||||
지난 기록 보기
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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);
|
||||
@@ -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 = `<div class="inline-loading"><span class="spinner small"></span>
|
||||
<span>AI가 이번 시험을 분석하고 있어요…</span></div>`;
|
||||
|
||||
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 =
|
||||
'내 답: <span></span> · 정답: <span></span><br>내가 밝힌 확신도: <span></span>';
|
||||
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 = `
|
||||
<p class="muted range"></p>
|
||||
<div class="big"></div>
|
||||
<p class="muted count"></p>
|
||||
<div class="insight">
|
||||
<div class="${score.overconfident ? 'danger' : ''}">
|
||||
<strong></strong>확신했지만 틀림
|
||||
</div>
|
||||
<div class="${score.lucky ? 'lucky' : ''}"><strong></strong>운으로 맞힘</div>
|
||||
<div class="${score.solid ? 'solid' : ''}"><strong></strong>확실히 아는 것</div>
|
||||
</div>
|
||||
<p class="muted note"></p>
|
||||
`;
|
||||
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;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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<string, Record<string, string[]>> = 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<T>(options: Parameters<typeof generate>[0] & { prompt: string; schema: object }) {
|
||||
return JSON.parse(await generate(options)) as T;
|
||||
}
|
||||
|
||||
const route =
|
||||
(handler: (req: express.Request) => Promise<unknown>) =>
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<GeneratedQuestion>({
|
||||
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<TestRecord['feedback']>({
|
||||
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})`);
|
||||
});
|
||||
@@ -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<unknown> = 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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user