수동 채점 시스템
선생님은 AI 자동 채점 결과를 검토하고, 필요한 경우 수동으로 점수를 수정하거나 추가 피드백을 제공할 수 있습니다.
개요
- 경로:
/c/[slug]/assignments/[assignmentId]/grading - 권한: Owner, Admin만 접근 가능
- 목적: AI 채점 결과를 검토하고 최종 점수를 확정
- 특징: AI 점수와 수동 점수 이원화 시스템
주요 개념
이원화 채점 시스템
AI 자동 채점
- 필드:
score,feedback - 시점: 학생이 과제를 제출하는 즉시
- 특징: 빠른 피드백, 일관성 있는 평가
- 한계: 창의성, 맥락 이해 부족
선생님 수동 채점
- 필드:
manualScore,teacherFeedback,gradedBy - 시점: 선생님이 직접 검토 후
- 특징: 세밀한 평가, 맞춤 피드백
- 우선순위: manualScore가 있으면 최종 점수로 사용
점수 우선순위
const finalScore = submission.manualScore ?? submission.score;
const finalFeedback = submission.teacherFeedback || submission.feedback;
표시 로직:
- manualScore가 있으면 → 수동 점수 표시 (선생님 뱃지)
- manualScore가 없으면 → AI 점수 표시 (AI 뱃지)
주요 기능
1. 채점 대기 목록
필터링 조건:
gradedBy IS NULL(수동 채점이 안 된 제출물)- AI 채점은 완료된 상태
표시 정보:
- 학생 이름
- 과제 제목
- AI 점수 / 만점
- AI 피드백
- 제출 시간
- "채점하기" 버튼
2. 개별 채점 페이지
과제 정보 섹션
- 과제 제목
- 과제 지시사항
- 만점
- 루브릭 (채점 기준)
제출물 섹션
- 학생 이름
- 제출 시간
- 제출 내용 (전체 텍스트)
- 재제출 여부
AI 채점 결과
- AI 점수 / 만점
- AI 피드백
- "AI 채점" 뱃지
수동 채점 입력
- 점수 입력 필드 (0 ~ maxScore)
- 선생님 피드백 텍스트 영역
- "채점 저장" 버튼
3. 인라인 편집
채점 목록에서 바로 점수 입력 가능:
- 점수 입력 필드
- 피드백 텍스트 영역
- "저장" 버튼
- 저장 후 목록 자동 갱신
API 엔드포인트
GET /api/assignments/[id]/submissions/[submissionId]/grade
제출물 상세 정보 조회 (채점용)
Response:
{
"submission": {
"id": "123",
"content": "학생이 제출한 과제 내용...",
"status": "submitted",
"score": 85,
"feedback": "AI가 생성한 피드백...",
"manualScore": null,
"teacherFeedback": null,
"gradedBy": null,
"submittedAt": "2025-12-14T10:30:00.000Z",
"gradedAt": null,
"isResubmission": false
},
"assignment": {
"id": "45",
"title": "한국어 작문 과제 1",
"instructions": "한국어로 자기소개를 작성하세요...",
"maxScore": 100,
"rubric": {
"grammar": { "weight": 30, "description": "문법 정확성" },
"vocabulary": { "weight": 30, "description": "어휘 다양성" },
"content": { "weight": 40, "description": "내용 충실도" }
}
},
"member": {
"id": 1,
"userId": 10
}
}
PUT /api/assignments/[id]/submissions/[submissionId]/grade
수동 채점 저장
Request:
{
"manualScore": 90,
"teacherFeedback": "문법은 완벽하지만 어휘를 더 다양하게 사용하면 좋겠습니다."
}
Response:
{
"success": true,
"submission": {
"id": "123",
"content": "학생이 제출한 과제 내용...",
"status": "graded",
"score": 85,
"feedback": "AI가 생성한 피드백...",
"manualScore": 90,
"teacherFeedback": "문법은 완벽하지만 어휘를 더 다양하게 사용하면 좋겠습니다.",
"gradedBy": 5,
"submittedAt": "2025-12-14T10:30:00.000Z",
"gradedAt": "2025-12-14T15:00:00.000Z"
}
}
Error Responses:
400: Score out of range (0 ~ maxScore)401: Unauthorized (로그인 필요)403: Forbidden (Owner/Admin만 가능)404: Assignment or submission not found
데이터베이스 스키마
assignmentSubmissions 테이블
{
id: serial("id").primaryKey(),
assignmentId: integer("assignment_id").notNull(),
memberId: integer("member_id").notNull(),
content: text("content").notNull(),
status: varchar("status", { length: 20 }).notNull(), // "submitted", "graded"
// AI 자동 채점
score: integer("score"), // AI 점수
feedback: text("feedback"), // AI 피드백
// 선생님 수동 채점
manualScore: integer("manual_score"), // 수동 점수 (최종 점수)
teacherFeedback: text("teacher_feedback"), // 선생님 추가 피드백
gradedBy: integer("graded_by").references(() => users.id), // null = AI만 채점
submittedAt: timestamp("submitted_at").notNull().defaultNow(),
gradedAt: timestamp("graded_at"), // 수동 채점 완료 시간
isResubmission: boolean("is_resubmission").default(false),
}
assignments 테이블 (관련 필드)
{
id: serial("id").primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
instructions: text("instructions").notNull(),
maxScore: integer("max_score").default(100),
rubric: json("rubric"), // 채점 기준 (JSON 형식)
allowResubmit: boolean("allow_resubmit").default(false),
}
채점 워크플로우
선생님 워크플로우
-
채점 대기 목록 확인
/c/[slug]/assignments/[assignmentId]/grading접속- AI 채점만 완료된 제출물 목록 확인
-
AI 채점 결과 검토
- AI 점수 및 피드백 확인
- 과제 내용 읽기
- 루브릭 기준과 비교
-
수동 채점
- AI 점수가 적절하면 → 그대로 사용 또는 소폭 조정
- AI 점수가 부적절하면 → 수동 점수 입력
- 추가 피드백 작성
-
저장 및 확인
- "채점 저장" 버튼 클릭
- 상태가 "graded"로 변경
- 학생에게 알림 발송 (향후 기능)
학생 관점
-
과제 제출
- 과제 내용 작성 후 제출
- AI 자동 채점 즉시 실행
-
AI 피드백 확인
- AI 점수 및 피드백 즉시 확인
- 임시 점수로 참고
-
최종 점수 확인
- 선생님 수동 채점 완료 후
- 최종 점수 (manualScore) 확인
- 선생님 피드백 읽기
루브릭 기반 채점
루브릭 구조 (JSON)
{
"grammar": {
"weight": 30,
"description": "문법 정확성 (맞춤법, 띄어쓰기, 문법 규칙)",
"levels": {
"excellent": "90-100: 완벽한 문법",
"good": "80-89: 사소한 실수 1-2개",
"fair": "70-79: 중간 수준의 실수 3-5개",
"poor": "0-69: 많은 문법 오류"
}
},
"vocabulary": {
"weight": 30,
"description": "어휘 다양성 및 적절성",
"levels": {
"excellent": "90-100: 다양하고 고급 어휘 사용",
"good": "80-89: 적절한 어휘 사용",
"fair": "70-79: 제한적인 어휘",
"poor": "0-69: 매우 제한적이거나 부적절한 어휘"
}
},
"content": {
"weight": 40,
"description": "내용 충실도 및 창의성",
"levels": {
"excellent": "90-100: 매우 창의적이고 충실한 내용",
"good": "80-89: 충실한 내용",
"fair": "70-79: 기본적인 내용만 포함",
"poor": "0-69: 불충분하거나 주제 이탈"
}
}
}
루브릭 표시
<RubricCard>
<h3>문법 정확성 (30%)</h3>
<p>맞춤법, 띄어쓰기, 문법 규칙</p>
<ul>
<li>90-100: 완벽한 문법</li>
<li>80-89: 사소한 실수 1-2개</li>
<li>70-79: 중간 수준의 실수 3-5개</li>
<li>0-69: 많은 문법 오류</li>
</ul>
</RubricCard>
UI 컴포넌트
채점 목록 페이지
<Table>
<TableHeader>
<TableRow>
<TableHead>학생</TableHead>
<TableHead>과제</TableHead>
<TableHead>AI 점수</TableHead>
<TableHead>수동 점수</TableHead>
<TableHead>제출 시간</TableHead>
<TableHead>액션</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submissions.map(sub => (
<TableRow key={sub.id}>
<TableCell>{sub.studentName}</TableCell>
<TableCell>{assignment.title}</TableCell>
<TableCell>
<Badge variant="secondary">AI</Badge> {sub.score}/{assignment.maxScore}
</TableCell>
<TableCell>
<Input
type="number"
placeholder="점수 입력"
value={editScore}
onChange={(e) => setEditScore(e.target.value)}
/>
</TableCell>
<TableCell>{formatDate(sub.submittedAt)}</TableCell>
<TableCell>
<Button onClick={() => saveGrade(sub.id)}>저장</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
채점 저장 로직
const saveGrade = async (submissionId: string) => {
const score = parseInt(editScore);
if (isNaN(score) || score < 0 || score > assignment.maxScore) {
toast.error(`점수는 0부터 ${assignment.maxScore}까지 입력 가능합니다.`);
return;
}
const response = await fetch(
`/api/assignments/${assignmentId}/submissions/${submissionId}/grade`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
manualScore: score,
teacherFeedback: editFeedback,
}),
}
);
if (response.ok) {
toast.success("채점이 저장되었습니다.");
fetchSubmissions(); // Refresh list
} else {
const data = await response.json();
toast.error(data.error || "채점 저장에 실패했습니다.");
}
};
점수 입력 검증
const validateScore = (score: number, maxScore: number) => {
if (isNaN(score)) {
return "점수는 숫자여야 합니다.";
}
if (score < 0) {
return "점수는 0보다 작을 수 없습니다.";
}
if (score > maxScore) {
return `점수는 ${maxScore}보다 클 수 없습니다.`;
}
return null;
};
점수 표시 로직
최종 점수 계산
const getFinalScore = (submission: Submission) => {
return submission.manualScore ?? submission.score;
};
const getFinalFeedback = (submission: Submission) => {
return submission.teacherFeedback || submission.feedback;
};
const isManuallyGraded = (submission: Submission) => {
return submission.manualScore !== null && submission.gradedBy !== null;
};
점수 뱃지 표시
<div>
{isManuallyGraded(submission) ? (
<Badge variant="default">선생님</Badge>
) : (
<Badge variant="secondary">AI</Badge>
)}
<span>{getFinalScore(submission)}/{assignment.maxScore}</span>
</div>
재제출 처리
재제출 허용 설정
// assignments 테이블
allowResubmit: boolean("allow_resubmit").default(false)
재제출 워크플로우
-
과제 생성 시
- allowResubmit = true 설정
-
학생이 재제출
- 새 제출물 생성
- isResubmission = true 설정
- 이전 제출물은 보관 (삭제하지 않음)
-
선생님 채점
- 가장 최근 제출물만 채점
- 재제출 뱃지 표시
권한 관리
Owner & Admin
- 모든 제출물 채점 가능
- AI 점수 덮어쓰기 가능
- 채점 기록 확인 가능 (gradedBy)
Student
- 채점 페이지 접근 불가
- 본인 제출물의 점수/피드백만 확인 가능
- 채점자 정보 확인 불가 (프라이버시)
확장 가능성
향후 추가 기능
- 🎯 루브릭 기반 채점 UI (항목별 점수 입력)
- 📊 채점 통계 (평균 채점 시간, AI vs 수동 점수 차이)
- 💬 학생과 선생님 간 피드백 대화
- 🔔 채점 완료 알림 (이메일, 푸시)
- 📝 채점 템플릿 (자주 쓰는 피드백 저장)
- 📈 채점 히스토리 (수정 내역 추적)
- 🤖 AI 채점 개선 (선생님 채점 패턴 학습)
- 👥 복수 채점자 지원 (동료 평가)
트러블슈팅
채점 저장이 실패하는 경우
- 점수 범위 확인 (0 ~ maxScore)
- Owner/Admin 권한 확인
- 제출물 ID가 유효한지 확인
- 네트워크 연결 상태 확인
AI 점수가 표시되지 않는 경우
- AI 채점이 실제로 완료되었는지 확인
score필드가 null이 아닌지 확인- 과제 제출 시 AI 채점 로직 동작 확인
수동 점수가 반영되지 않는 경우
manualScore필드가 올바르게 저장되었는지 확인- 페이지 새로고침
- 캐시 무효화 (React Query invalidateQueries)
루브릭이 표시되지 않는 경우
- 과제에 rubric이 설정되어 있는지 확인
- JSON 형식이 올바른지 확인
- null 체크 추가