import express from 'express'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import { GoogleGenAI, Type } from '@google/genai'; import * as store from './store.ts'; import type { TestQuestion, TestRecord } from './store.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const API_KEY = process.env.GEMINI_API_KEY; if (!API_KEY) { console.error( 'GEMINI_API_KEY 가 설정되지 않았습니다. LG/.env 파일에 GEMINI_API_KEY=... 를 추가하세요.\n' + '키 발급: https://aistudio.google.com/apikey' ); process.exit(1); } const ai = new GoogleGenAI({ apiKey: API_KEY }); const MODEL = process.env.GEMINI_MODEL ?? 'gemini-3.5-flash-lite'; const chapters: Record> = JSON.parse( await fs.readFile(path.join(__dirname, 'chapter.json'), 'utf8') ); await store.load(); const app = express(); app.use(express.json({ limit: '1mb' })); app.use('/public', express.static(path.join(__dirname, 'public'))); app.get('/', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); async function generate(options: { prompt?: string; contents?: unknown; systemInstruction: string; schema?: object; maxOutputTokens?: number; temperature?: number; }) { const response = await ai.models.generateContent({ model: MODEL, contents: (options.contents ?? options.prompt) as never, config: { systemInstruction: options.systemInstruction, temperature: options.temperature ?? 0.8, maxOutputTokens: options.maxOutputTokens ?? 2048, ...(options.schema ? { responseMimeType: 'application/json', responseSchema: options.schema } : {}), }, }); const text = response.text?.trim(); if (!text) { const reason = response.candidates?.[0]?.finishReason ?? 'UNKNOWN'; console.error('모델이 빈 응답을 반환했습니다.', reason, response.usageMetadata); throw new HttpError(502, `AI가 빈 응답을 보냈습니다 (${reason}). 다시 시도해 주세요.`); } return text; } async function generateJson(options: Parameters[0] & { prompt: string; schema: object }) { return JSON.parse(await generate(options)) as T; } const route = (handler: (req: express.Request) => Promise) => async (req: express.Request, res: express.Response) => { try { res.json(await handler(req)); } catch (err) { if (err instanceof HttpError) { res.status(err.status).json({ error: err.message }); return; } console.error(err); res.status(502).json({ error: 'AI 응답을 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.', }); } }; class HttpError extends Error { constructor(readonly status: number, message: string) { super(message); } } const QUESTION_PROPERTIES = { question: { type: Type.STRING }, correct_answer: { type: Type.STRING }, incorrect_answers: { type: Type.ARRAY, items: { type: Type.STRING } }, }; const QUESTIONS_SCHEMA = { type: Type.OBJECT, properties: { results: { type: Type.ARRAY, items: { type: Type.OBJECT, properties: QUESTION_PROPERTIES, required: ['question', 'correct_answer', 'incorrect_answers'], }, }, }, required: ['results'], }; const TWIN_SCHEMA = { type: Type.OBJECT, properties: QUESTION_PROPERTIES, required: ['question', 'correct_answer', 'incorrect_answers'], }; const FEEDBACK_SCHEMA = { type: Type.OBJECT, properties: { summary: { type: Type.STRING }, detail: { type: Type.STRING }, recommendation: { type: Type.STRING }, }, required: ['summary', 'detail', 'recommendation'], }; const DIFFICULTY_KO: Record = { easy: '쉬움 (기본 개념 확인)', medium: '보통 (개념 응용)', hard: '어려움 (여러 개념의 종합 및 분석)', }; interface GeneratedQuestion { question: string; correct_answer: string; incorrect_answers: string[]; } const isUsable = (q: GeneratedQuestion) => Boolean(q?.question && q?.correct_answer && q?.incorrect_answers?.length); app.get('/api/chapters', (_req, res) => res.json(chapters)); app.post( '/api/questions', route(async (req) => { const school = String(req.body?.school ?? ''); const subject = String(req.body?.subject ?? ''); const chapter = String(req.body?.chapter ?? ''); if (!chapters[school]?.[subject]?.includes(chapter)) { throw new HttpError(400, '학교 종류·과목·단원을 다시 선택해 주세요.'); } const difficulty = String(req.body?.difficulty ?? 'medium'); const amount = Math.min(Math.max(Number(req.body?.amount) || 5, 1), 20); const { results = [] } = await generateJson<{ results?: GeneratedQuestion[] }>({ systemInstruction: '당신은 학생의 메타인지 학습을 돕는 한국 중·고등학교 교사입니다. ' + '교육과정에 맞는 정확한 4지선다 객관식 문제를 출제합니다. ' + '정답은 반드시 하나이며, 오답도 그럴듯해야 합니다. 모든 텍스트는 한국어로 작성합니다.', prompt: `학교: ${school}\n과목: ${subject}\n단원: ${chapter}\n` + `난이도: ${DIFFICULTY_KO[difficulty] ?? DIFFICULTY_KO.medium}\n` + `문항 수: ${amount}\n\n` + '위 조건으로 서로 겹치지 않는 4지선다 문제를 만들어 주세요. ' + '단원 안에서도 세부 개념이 골고루 나오게 하고, ' + 'incorrect_answers 에는 오답 3개만 넣고, 보기 앞에 번호나 기호를 붙이지 마세요.', schema: QUESTIONS_SCHEMA, temperature: 1.0, maxOutputTokens: 8192, }); const usable = results.filter(isUsable).slice(0, amount); if (!usable.length) throw new Error('빈 문항 응답'); return { results: usable }; }) ); app.post( '/api/tests', route(async (req) => { const body = req.body ?? {}; const questions: TestQuestion[] = (body.questions ?? []).map((q: any) => ({ question: String(q.question ?? ''), correct_answer: String(q.correct_answer ?? ''), incorrect_answers: (q.incorrect_answers ?? []).map(String), user_answer: q.user_answer ?? null, confidence: q.confidence ?? null, is_correct: q.user_answer === q.correct_answer, explanation: null, conversation: [], children: [], })); if (!questions.length) throw new HttpError(400, '저장할 문항이 없습니다.'); const correct = questions.filter((q) => q.is_correct).length; const record: TestRecord = { id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, createdAt: new Date().toISOString(), student: String(body.student || '학생').slice(0, 30), school: String(body.school ?? ''), subject: String(body.subject ?? ''), chapter: String(body.chapter ?? ''), difficulty: String(body.difficulty ?? 'medium'), score: { total: questions.length, correct, percent: Math.round((correct / questions.length) * 100), overconfident: questions.filter((q) => q.confidence === 'sure' && !q.is_correct) .length, lucky: questions.filter((q) => q.confidence !== 'sure' && q.is_correct).length, solid: questions.filter((q) => q.confidence === 'sure' && q.is_correct).length, }, feedback: null, questions, }; await store.save(record); return record; }) ); app.get('/api/tests', (_req, res) => res.json({ tests: store.listSummaries() })); app.get('/api/tests/:id', (req, res) => { const record = store.get(req.params.id); if (!record) { res.status(404).json({ error: '기록을 찾을 수 없습니다.' }); return; } res.json(record); }); function findQuestion(req: express.Request) { const record = store.get(String(req.params.id)); const index = Number(req.params.index); const question = record?.questions[index]; if (!record || !question) throw new HttpError(404, '문항을 찾을 수 없습니다.'); return { record, index, question }; } app.post( '/api/tests/:id/questions/:index/explain', route(async (req) => { const { record, index, question } = findQuestion(req); if (question.explanation) return { explanation: question.explanation }; const explanation = await generate({ systemInstruction: 'CogMind 는 학생의 메타인지를 돕는 피드백 도우미입니다. 한국어 존댓말로, 250자 내외로 답합니다.\n' + '1) 정답의 근거를 한 문장으로 설명합니다.\n' + '2) 틀렸다면 학생이 어디서 잘못 생각했는지 짚어 줍니다.\n' + '3) 확신했는데 틀렸다면 그 개념을 다시 확인해야 한다고 분명히 말합니다. ' + '찍어서 맞혔다면 아직 아는 것이 아니라고 알려 줍니다.\n' + '마크다운 기호 없이 자연스러운 문단으로 작성하세요.', prompt: `단원: ${record.subject} · ${record.chapter}\n` + `문제: ${question.question}\n` + `정답: ${question.correct_answer}\n` + `학생의 답: ${question.user_answer ?? '(무응답)'} (${ question.is_correct ? '정답' : '오답' })\n` + `학생이 밝힌 확신도: ${CONFIDENCE_KO[question.confidence ?? ''] ?? '미응답'}`, temperature: 0.6, maxOutputTokens: 1024, }); await store.setExplanation(record.id, index, explanation); return { explanation }; }) ); const CONFIDENCE_KO: Record = { sure: '확실해요', unsure: '애매해요', guess: '찍었어요', }; const CHAT_WINDOW = 20; app.post( '/api/tests/:id/questions/:index/ask', route(async (req) => { const { record, index, question } = findQuestion(req); if (!question.explanation) throw new HttpError(400, '먼저 AI 해설을 생성해 주세요.'); const message = String(req.body?.message ?? '').trim().slice(0, 1000); if (!message) throw new HttpError(400, '질문을 입력해 주세요.'); const contents = [ ...question.conversation.slice(-CHAT_WINDOW).map((m) => ({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.content }], })), { role: 'user', parts: [{ text: message }] }, ]; const answer = await generate({ systemInstruction: 'CogMind 는 학생이 방금 푼 문제의 해설에 이어서 질문할 수 있는 1:1 튜터입니다. ' + '한국어 존댓말로, 400자 안쪽으로 답합니다. ' + '학생이 스스로 이해하도록 개념을 짚어 주고, 필요하면 예시를 듭니다. ' + '문제와 관계없는 질문에는 이 문제와 관련해 도울 수 있다고 안내하세요. ' + '모르는 것은 아는 척하지 말고 모른다고 말합니다. ' + '마크다운 기호 없이 자연스러운 문단으로 작성하세요.\n\n' + '--- 학생이 푼 문제 ---\n' + `단원: ${record.school} ${record.subject} · ${record.chapter}\n` + `문제: ${question.question}\n` + `보기: ${[question.correct_answer, ...question.incorrect_answers].join(', ')}\n` + `정답: ${question.correct_answer}\n` + `학생의 답: ${question.user_answer ?? '(무응답)'} (${ question.is_correct ? '정답' : '오답' })\n` + `학생이 밝힌 확신도: ${CONFIDENCE_KO[question.confidence ?? ''] ?? '미응답'}\n` + `학생이 읽은 해설: ${question.explanation}\n`, contents, temperature: 0.6, maxOutputTokens: 4096, }); const now = new Date().toISOString(); const messages = [ { role: 'user' as const, content: message, createdAt: now }, { role: 'assistant' as const, content: answer, createdAt: new Date().toISOString() }, ]; await store.addMessages(record.id, index, messages); return { messages }; }) ); app.post( '/api/tests/:id/questions/:index/twin', route(async (req) => { const { record, index, question } = findQuestion(req); const twin = await generateJson({ systemInstruction: '당신은 쌍둥이 문제를 만드는 교사입니다. 원본 문제와 같은 개념·난이도를 묻되, ' + '소재와 숫자는 다른 새 4지선다 문제를 하나 만듭니다. ' + 'incorrect_answers 에는 오답 3개만 넣고, 보기 앞에 번호나 기호를 붙이지 마세요. ' + '모든 텍스트는 한국어로 작성합니다.', prompt: `단원: ${record.subject} · ${record.chapter}\n` + `원본 문제: ${question.question}\n원본 정답: ${question.correct_answer}\n` + `이미 만든 쌍둥이 문제 ${record.questions[index]!.children.length}개와도 겹치지 않게 해주세요.`, schema: TWIN_SCHEMA, temperature: 1.0, maxOutputTokens: 2048, }); if (!isUsable(twin)) throw new Error('빈 쌍둥이 문제 응답'); const child = { ...twin, createdAt: new Date().toISOString() }; await store.addChild(record.id, index, child); return { twin: child }; }) ); app.post( '/api/tests/:id/feedback', route(async (req) => { const record = store.get(String(req.params.id)); if (!record) throw new HttpError(404, '기록을 찾을 수 없습니다.'); if (record.feedback) return { feedback: record.feedback }; const lines = record.questions .map( (q, i) => `${i + 1}. ${q.question}\n` + ` 정답: ${q.correct_answer} / 학생의 답: ${q.user_answer ?? '무응답'} ` + `(${q.is_correct ? 'O' : 'X'}, 확신도: ${CONFIDENCE_KO[q.confidence ?? ''] ?? '미응답'})` ) .join('\n'); const feedback = await generateJson({ systemInstruction: 'CogMind 는 학생의 메타인지를 진단하는 학습 코치입니다. 한국어 존댓말로 작성합니다.\n' + 'summary: 학생 이름을 넣어 어느 개념이 부족한지 한 문장으로 진단합니다. ' + '예) "OO학생은 미분 부분에 부족함이 있습니다."\n' + 'detail: 무엇은 되고 무엇이 안 되는지 근거를 들어 2~3문장으로 씁니다. ' + '특히 확신했는데 틀린 문항은 "안다고 착각한 개념"으로 분명히 짚어 줍니다. ' + '찍어서 맞힌 문항은 아직 아는 것이 아니라고 알려 줍니다.\n' + 'recommendation: 구체적인 교재·단원·학습 방법을 제시하고, ' + '마지막에 CogMind 에서 맞춤형 문제를 더 풀어 보라고 권합니다.\n' + '마크다운 기호 없이 자연스러운 문장으로 작성하세요.', prompt: `학생 이름: ${record.student}\n` + `범위: ${record.school} ${record.subject} · ${record.chapter} (난이도 ${ DIFFICULTY_KO[record.difficulty] ?? record.difficulty })\n` + `점수: ${record.score.total}문제 중 ${record.score.correct}문제 정답 (${record.score.percent}점)\n` + `확신했지만 틀림 ${record.score.overconfident}문제 / 운으로 맞힘 ${record.score.lucky}문제 / ` + `확실히 아는 것 ${record.score.solid}문제\n\n` + `문항별 결과:\n${lines}`, schema: FEEDBACK_SCHEMA, temperature: 0.7, maxOutputTokens: 2048, }); await store.setFeedback(record.id, feedback); return { feedback }; }) ); app.get('*', (req, res) => { if (req.path.startsWith('/api/') || req.path.startsWith('/public/')) { res.status(404).json({ error: '찾을 수 없습니다.' }); return; } res.sendFile(path.join(__dirname, 'public', 'index.html')); }); const PORT = Number(process.env.PORT) || 1234; app.listen(PORT, () => { console.log(`CogMind 실행 중 → http://127.0.0.1:${PORT} (model: ${MODEL})`); });