337 lines
11 KiB
JavaScript
337 lines
11 KiB
JavaScript
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;
|
|
};
|