first commit
This commit is contained in:
@@ -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})`);
|
||||
});
|
||||
Reference in New Issue
Block a user