Files
2026-08-10 21:35:17 +09:00

421 lines
17 KiB
TypeScript

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})`);
});