수동 채점 시스템

선생님은 AI 자동 채점 결과를 검토하고, 필요한 경우 수동으로 점수를 수정하거나 추가 피드백을 제공할 수 있습니다.

개요

주요 개념

이원화 채점 시스템

AI 자동 채점

선생님 수동 채점

점수 우선순위

const finalScore = submission.manualScore ?? submission.score; const finalFeedback = submission.teacherFeedback || submission.feedback;

표시 로직:

주요 기능

1. 채점 대기 목록

필터링 조건:

표시 정보:

2. 개별 채점 페이지

과제 정보 섹션

제출물 섹션

AI 채점 결과

수동 채점 입력

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:

데이터베이스 스키마

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), }

채점 워크플로우

선생님 워크플로우

  1. 채점 대기 목록 확인

    • /c/[slug]/assignments/[assignmentId]/grading 접속
    • AI 채점만 완료된 제출물 목록 확인
  2. AI 채점 결과 검토

    • AI 점수 및 피드백 확인
    • 과제 내용 읽기
    • 루브릭 기준과 비교
  3. 수동 채점

    • AI 점수가 적절하면 → 그대로 사용 또는 소폭 조정
    • AI 점수가 부적절하면 → 수동 점수 입력
    • 추가 피드백 작성
  4. 저장 및 확인

    • "채점 저장" 버튼 클릭
    • 상태가 "graded"로 변경
    • 학생에게 알림 발송 (향후 기능)

학생 관점

  1. 과제 제출

    • 과제 내용 작성 후 제출
    • AI 자동 채점 즉시 실행
  2. AI 피드백 확인

    • AI 점수 및 피드백 즉시 확인
    • 임시 점수로 참고
  3. 최종 점수 확인

    • 선생님 수동 채점 완료 후
    • 최종 점수 (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)

재제출 워크플로우

  1. 과제 생성 시

    • allowResubmit = true 설정
  2. 학생이 재제출

    • 새 제출물 생성
    • isResubmission = true 설정
    • 이전 제출물은 보관 (삭제하지 않음)
  3. 선생님 채점

    • 가장 최근 제출물만 채점
    • 재제출 뱃지 표시

권한 관리

Owner & Admin

Student

확장 가능성

향후 추가 기능

트러블슈팅

채점 저장이 실패하는 경우

AI 점수가 표시되지 않는 경우

수동 점수가 반영되지 않는 경우

루브릭이 표시되지 않는 경우