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