first commit

This commit is contained in:
2026-08-10 21:35:17 +09:00
commit 02d9d0785b
22 changed files with 3853 additions and 0 deletions
+8
View File
@@ -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
View File
@@ -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/
+52
View File
@@ -0,0 +1,52 @@
# 메타인지란
MetaCognitive ability(메타인지)란 meta(초월)와 cognitive(인지)의 합성어로 인지에 대한 인지를 하는 능력으로 1976년, 미국의 심리학자 존 플래벌(John Flavell)에 의해 처음 도입되었다. 이를 쉽게 풀어말하자면, 내가하는 생각에 대해서 생각하는 것이다.
이는 크게 **메타인지 지식**, **메타인지 조절** 로 나뉜다.
## 메타인지 지식
메타인지 지식은 개인이 자신의 인지 과정을 얼마나 잘 파악하고 있는지 나타낸다. 이를 통해 자신의 학습 과정과 방법을 더 잘 이해할 수 있다. 이는 더 세분화되어 **선언적 지식**, **절차적 지식**, **조건적 지식** 유형으로 나뉜다.
### 선언전 지식
선언적 지식은 자신이 학습하는 내용에서 어떤 개념, 사실, 정보를 아는지에 대한 지식이다.
### 절차적 지식
절차적 지식이란 특정 작업의 순서나 절차를 새우고, 알고리즘을 만드는 능력이다.
### 조건적 지식
조건적 지식이란 상황에 알맞은 지식이나 절차를 선택하는 능력이다.
## 메타인지 조절
메타인지 조절은 자신의 학습 상황을 분석하는 능력이다. 이를 통해서는 자신의 학습 태도, 상황들에 대한 이해도를 높일 수 있으며 학습 과정을 효과적으로 개선할 수 있다. 이 또한 **계획**, **모니터링**, **평가** 로 나뉜다.
### 계획
학습이나, 문제 풀이를 시작하기전 어떻게 진행할지 구상하는 능력이다.
### 모니터링
학습을 진행하는 동안 자신의 상태를 감시하고 분석하는 능력이다.
### 평가
학습이 끝난 후, 그 결과를 평가하는 능력이다.
___
# 메타인지의 활용
메타인지는 그 자체로 인간의 정신적 발전에 굉장히 뛰어난 개념이기에 수많은 사람들이 활용활 수 있다. 그중에서도 메타인지는 많은 학습을 해야하는 학생들에게 뛰어난 활용성을 지닌다. 학생은 #메타인지 지식을 통해 자신의 현재 학습 성취도를 분석할 수 있으며, #메타인지 조절을 통해 자신의 공부 방법을 조절할 수 있다.
그럼, 학습에서의 메타인지 적용 단계에 대해 더 자세히 설명해보겠다.
먼저 메타인지 지식은 학습을 하는 동안 이용된다.
1. 선언적 지식 : 개념을 공부할 때 해당 개념에 대한 이해도를 평가한다.
2. 절차적 지식 : 문제 풀이를 할 때, 정확한 절차를 이용하는지 평가한다.
3. 조건적 지식 : 시험에서 각 문제마다 올바른 절차를 세우는데 이용된다.
메타인지 조절은 학습을 보다 더 효율적으로 할 수 있게 해준다.
1. 계획 : 지난 학습을 바탕으로 나에게 부족한 부분을 보충할 수 있게 계획을 세운다.
2. 모니터링 : 메타인지 지식을 활용하면서 학습을 진행하며, 학습 도중 내가 메타인지 지식을 잘 활용하고 있는지 확인한다.
3. 평가 : 계획과 일치하는 결과가 도출되었고, 모니터링 결과가 우수한지 확인한다. 이를 바탕으로 다음 학습에 더욱더 발전된 계획을 세울 수 있다.
___
# 우리 서비스에 적용
위와 같이 학습을 할 때, 메타인지를 잘 활용한다면 좋은 결과를 얻어 공부의 효율성을 향상시킬 수 있으면을 수 있겠지만, 메타인지가 부족한 경우에는 반대의 결과가 도출된다.
따라서, 우리는 메타인지 능력이 부족한 학생들에게 메타인지 과정을 도와주는 서비스 CogMind를 구상하였다. CogMind는 온라인 서비스이기 때문에 조건적 지식, 모니터링을 제외한 나머지 메타인지 유형을 바탕으로 구상하였다. 그리하여, 서비스에는 크게 테스트 파트|테스트 파트(메타인지 지식)과 피드백 파트|피드백 및 계획을 제공해주는 파트(메타인지 조절)로 나눠진다.
## 테스트 파트
이 파트는 학생들의 학업 성취도를 평가하기 위한 테스트를 생성하는 과정이다. 이 때, 테스트는 다양한 개념의 문제들을 포함하여, 학생의 종합적인 성취도를 확인할 수 있어야 하며, 각각의 개념에 대해 정확하게 아는지 평가하기 위한 문제선정 알고리즘이 필요하며 이를 도와주기 위해 인공지능이 필요하다.
## 피드백 파트
위의 테스트를 통한 결과를 분석하고 분석을 기반으로 학생의 학업 성취에 대한 피드백과 분석 결과를 제공한다. 이때, 정확하고 명료한 피드백을 제공하기 위해서 피드백에 대한 설문과 학습이 필요하다.
+98
View File
@@ -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>)
```
Executable
BIN
View File
Binary file not shown.
+52
View File
@@ -0,0 +1,52 @@
{
"고등학교": {
"수학": [
"다항식",
"방정식과 부등식",
"도형의 방정식",
"집합과 명제",
"함수",
"경우의 수",
"지수함수와 로그",
"삼각함수",
"수열",
"함수의 극한과 연속",
"다항항수의 미분법",
"다항항수의 적분법",
"급수",
"도함수",
"행렬"
],
"과학": [
"힘과 운동",
"에너지와 열",
"시간과 공간",
"물질의 전기적 특성",
"물질의 자기적 특성"
]
},
"중학교": {
"수학": [
"소인수분해",
"정수와 유리수",
"방정식",
"그래프와 비례",
"기본도형",
"평면도형",
"입체도형",
"통계",
"수와 식",
"부등식",
"방정식",
"함수",
"도형의 성질",
"도형의 닮음",
"피타고라스 정리",
"확률",
"제곱근과 실수",
"다항식의 곱셈과 인수분해",
"이차방정식",
"이차함수"
]
}
}
+2
View File
@@ -0,0 +1,2 @@
[tools]
bun = "latest"
+1063
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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"
}
}
+125
View File
@@ -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

+25
View File
@@ -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
View File
@@ -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
View File
@@ -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())
);
+144
View File
@@ -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">문항 수 (120)</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);
+336
View File
@@ -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;
};
+136
View File
@@ -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);
+420
View File
@@ -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})`);
});
+143
View File
@@ -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;
}
+23
View File
@@ -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"]
}