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
+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;
};