diff --git a/backend/src/main/java/dev/awesomedev/mirim/service/CodingService.java b/backend/src/main/java/dev/awesomedev/mirim/service/CodingService.java index dddaeb7..d6ac167 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/service/CodingService.java +++ b/backend/src/main/java/dev/awesomedev/mirim/service/CodingService.java @@ -26,13 +26,16 @@ public class CodingService { private final CodingProblemRepository codingProblemRepository; private final CodingSubmissionRepository codingSubmissionRepository; private final TelegramNotifier telegramNotifier; + private final LlmFeedbackDrafter llmFeedbackDrafter; public CodingService(CodingProblemRepository codingProblemRepository, CodingSubmissionRepository codingSubmissionRepository, - TelegramNotifier telegramNotifier) { + TelegramNotifier telegramNotifier, + LlmFeedbackDrafter llmFeedbackDrafter) { this.codingProblemRepository = codingProblemRepository; this.codingSubmissionRepository = codingSubmissionRepository; this.telegramNotifier = telegramNotifier; + this.llmFeedbackDrafter = llmFeedbackDrafter; } /** 전체 문제 목록 (정렬 순서대로) */ @@ -112,6 +115,19 @@ public class CodingService { return submission; } + /** + * 멘토 피드백 초안을 로컬 LLM에게 받아 온다. 저장은 하지 않는다 — 멘토가 보고 고칠 재료다. + * 모델이 없거나 실패하면 null(멘토가 손으로 쓰면 된다). 상세는 {@link LlmFeedbackDrafter}. + */ + @Transactional(readOnly = true) + public String draftFeedback(Long submissionId) { + CodingSubmission submission = codingSubmissionRepository.findById(submissionId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "제출물을 찾을 수 없습니다.")); + CodingProblem problem = submission.getProblem(); + return llmFeedbackDrafter.draft( + problem.getLanguage(), problem.getTitle(), problem.getDescription(), submission.getCode()); + } + /** 전체 제출물 — 멘토 화면용 (최신순) */ @Transactional(readOnly = true) public List allSubmissions() { diff --git a/backend/src/main/java/dev/awesomedev/mirim/service/LlmFeedbackDrafter.java b/backend/src/main/java/dev/awesomedev/mirim/service/LlmFeedbackDrafter.java new file mode 100644 index 0000000..521be65 --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/service/LlmFeedbackDrafter.java @@ -0,0 +1,141 @@ +package dev.awesomedev.mirim.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * 이 파일이 하는 일: + * 로컬 LLM(Ollama)에게 "학생 코드에 대한 멘토 피드백 초안"을 받아 온다. + * 최종 피드백은 멘토가 쓴다 — 이건 백지 대신 고칠 초안을 줄 뿐이다. + * + * 학습 포인트 ① — LLM은 "판정자"가 아니라 "초안 작성자"다. + * 이 앱의 CodingService 주석에 이미 있다: "진짜 판정은 멘토가 코드 원문을 읽고 내린다". + * 그 원칙을 그대로 지킨다. LLM 출력을 그대로 저장하지 않는다 — 멘토 화면에 초안으로 + * 띄우고, 멘토가 고쳐서 기존 피드백 저장 API로 보낸다. 왜 이렇게까지 하냐면, + * 로컬 모델은 정상 코드에도 없는 버그를 지어낼 때가 있기 때문이다(측정으로 확인됨). + * 그래서 "빠른 초안 + 사람 검수"가 유일하게 안전한 사용법이다. + * + * 학습 포인트 ② — 부가 기능이 본 기능을 죽이면 안 된다 (TelegramNotifier와 같은 원칙). + * 초안 생성이 실패하거나 모델이 없어도, 멘토는 늘 하던 대로 손으로 피드백을 쓸 수 있어야 + * 한다. 그래서 예외를 밖으로 던지지 않고 null을 돌려준다 — 호출한 쪽은 "초안 없음"으로 + * 처리하면 된다. 모델 URL이 비어 있으면 아예 호출도 안 한다(로컬에 모델이 없어도 앱이 돈다). + * + * 학습 포인트 ③ — 왜 하필 이 설정인가 (E:\llm 평가 결과에서 가져온 값). + * · /api/chat 을 쓴다. /api/generate 로 부르면 이 모델(gpt-oss)이 추론만 하다 답을 못 낸다. + * · num_ctx 16384. 작으면 추론에 예산을 다 쓰고 답변이 빈다. + * · 모델은 gpt-oss:20b. 버그 설명 정확도가 로컬 모델 중 유일하게 쓸 만했다(6/6). + * 빠른 qwen 계열은 async 버그를 "정상"이라 놓쳐서 피드백에는 위험하다. + */ +@Service +public class LlmFeedbackDrafter { + + private static final Logger log = LoggerFactory.getLogger(LlmFeedbackDrafter.class); + + private final String baseUrl; + private final String model; + private final int numCtx; + private final RestClient restClient; + + public LlmFeedbackDrafter( + @Value("${llm.base-url:}") String baseUrl, + @Value("${llm.model:gpt-oss:20b}") String model, + @Value("${llm.num-ctx:16384}") int numCtx) { + this.baseUrl = baseUrl; + this.model = model; + this.numCtx = numCtx; + // 로컬 모델은 답변 전에 추론을 길게 한다 — 읽기 타임아웃을 넉넉히(5분) 잡는다. + // 연결 자체는 같은 머신이라 금방 된다. + this.restClient = RestClient.builder() + .baseUrl(baseUrl.isBlank() ? "http://127.0.0.1:11434" : baseUrl) + .requestFactory(timeouts(Duration.ofSeconds(5), Duration.ofMinutes(5))) + .build(); + if (!isEnabled()) { + log.info("LLM 피드백 초안 비활성 상태입니다 (llm.base-url 미설정)."); + } + } + + private static org.springframework.http.client.ClientHttpRequestFactory timeouts( + Duration connect, Duration read) { + var f = new org.springframework.http.client.SimpleClientHttpRequestFactory(); + f.setConnectTimeout((int) connect.toMillis()); + f.setReadTimeout((int) read.toMillis()); + return f; + } + + private boolean isEnabled() { + return !baseUrl.isBlank(); + } + + /** + * 학생 제출 코드에 대한 피드백 초안을 만든다. 실패하거나 비활성이면 null. + * + * @param language 문제 언어 (JavaScript / SQL / Java) + * @param title 문제 제목 + * @param description 문제 설명 + * @param studentCode 학생이 낸 코드 원문 + * @return 피드백 초안 텍스트, 또는 null(멘토가 손으로 쓰면 된다) + */ + public String draft(String language, String title, String description, String studentCode) { + if (!isEnabled() || studentCode == null || studentCode.isBlank()) { + return null; + } + String prompt = buildPrompt(language, title, description, studentCode); + try { + // Ollama /api/chat 를 non-stream 으로 부른다. 응답은 message.content 에 온다 + // (추론은 message.thinking 으로 따로 오지만 피드백에는 쓰지 않는다). + @SuppressWarnings("unchecked") + Map body = restClient.post() + .uri("/api/chat") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of( + "model", model, + "stream", false, + "messages", List.of(Map.of("role", "user", "content", prompt)), + "options", Map.of("num_ctx", numCtx, "temperature", 0))) + .retrieve() + .body(Map.class); + + if (body == null) return null; + Object message = body.get("message"); + if (!(message instanceof Map m)) return null; + Object content = m.get("content"); + String text = content == null ? "" : content.toString().trim(); + return text.isBlank() ? null : text; + } catch (Exception e) { + // 실패는 조용히 삼킨다 — 멘토는 늘 하던 대로 손으로 쓰면 된다. + log.warn("LLM 피드백 초안 생성 실패(무시하고 계속): {}", e.getClass().getSimpleName()); + return null; + } + } + + private String buildPrompt(String language, String title, String description, String studentCode) { + // 실측 근거: gpt-oss 는 "정직하게 보고 정상이면 정상이라 하라"는 지시를 지켜, + // 정상 코드에 없는 버그를 지어내지 않았다(환각저항 3/3). 그 지시를 그대로 넣는다. + return """ + 너는 프로그래밍 멘토의 보조다. 아래는 학생이 낸 %s 코드다. + 멘토가 검토하고 다듬을 **피드백 초안**을 한국어로 써라. 다음을 지켜라. + + - 정답 코드를 통째로 주지 마라. 학생이 스스로 고치도록 방향만 짚어라. + - 코드를 정직하게 봐라. 정말 문제가 없으면 없는 버그를 지어내지 말고 잘한 점을 말하라. + - 문제가 있으면 무엇이 왜 문제인지, 어디를 다시 보면 되는지 구체적으로. + - 말투는 학생을 격려하되 정확하게. 3~5문장. + + 문제: [%s] %s + %s + + 학생 코드: + ``` + %s + ``` + """.formatted(language, language, title, + description == null ? "" : description, studentCode); + } +} diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java b/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java index c59f246..98d68b3 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java +++ b/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java @@ -337,4 +337,19 @@ public class MentorController { return MentorCodingSubmissionResponse.from( codingService.giveFeedback(id, feedbackRequest.feedback())); } + + /** 초안 응답 — draft가 null이면 "모델이 없거나 실패", 멘토는 손으로 쓰면 된다. */ + public record FeedbackDraft(String draft) { + } + + /** + * POST /api/mentor/coding-submissions/{id}/feedback-draft + * 로컬 LLM이 만든 피드백 초안을 돌려준다. 저장은 하지 않는다 — + * 멘토가 이 초안을 보고 고쳐서 위의 feedback API로 최종 저장한다. + * 이 앱의 원칙 그대로: LLM은 초안 작성자, 판정은 멘토. + */ + @PostMapping("/coding-submissions/{id}/feedback-draft") + public FeedbackDraft draftCodingFeedback(@PathVariable Long id) { + return new FeedbackDraft(codingService.draftFeedback(id)); + } } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 85a7d0f..9afbf7f 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -37,6 +37,14 @@ app: # 지금 가입하는 학생이 편입될 기수(코호트). 새 기수를 받을 땐 운영 .env에서 이 값만 바꾼다. current-cohort: ${CURRENT_COHORT:2026} +# 로컬 LLM(Ollama) — 멘토 피드백 초안 생성용. base-url이 비면 기능이 통째로 꺼진다. +# 로컬/운영 어디서도 모델이 없으면 앱은 그대로 돌고, 멘토는 손으로 피드백을 쓴다. +# 켜려면 .env에 LLM_BASE_URL=http://127.0.0.1:11434 를 넣는다(같은 머신에 Ollama가 떠 있을 때). +llm: + base-url: ${LLM_BASE_URL:} + model: ${LLM_MODEL:gpt-oss:20b} + num-ctx: ${LLM_NUM_CTX:16384} + server: port: 8080 error: diff --git a/backend/src/test/java/dev/awesomedev/mirim/service/CodingServiceTest.java b/backend/src/test/java/dev/awesomedev/mirim/service/CodingServiceTest.java index 1e0a4c3..9d7776f 100644 --- a/backend/src/test/java/dev/awesomedev/mirim/service/CodingServiceTest.java +++ b/backend/src/test/java/dev/awesomedev/mirim/service/CodingServiceTest.java @@ -39,7 +39,8 @@ class CodingServiceTest { codingProblemRepository = mock(CodingProblemRepository.class); codingSubmissionRepository = mock(CodingSubmissionRepository.class); codingService = new CodingService(codingProblemRepository, codingSubmissionRepository, - mock(TelegramNotifier.class)); // 테스트에선 진짜 알림을 보내지 않는다 + mock(TelegramNotifier.class), // 테스트에선 진짜 알림을 보내지 않는다 + mock(LlmFeedbackDrafter.class)); // LLM 초안도 모킹 — 이 테스트는 제출 규칙만 검증한다 student = new User("student1", "해시", "수습생1", "STUDENT", "DEV"); } diff --git a/frontend/src/components/MentorCodingSection.jsx b/frontend/src/components/MentorCodingSection.jsx index 4209e9e..085cdf6 100644 --- a/frontend/src/components/MentorCodingSection.jsx +++ b/frontend/src/components/MentorCodingSection.jsx @@ -66,6 +66,9 @@ export default function MentorCodingSection() { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [loadError, setLoadError] = useState(false); + // AI 초안: 로컬 LLM이 만든 피드백 초안을 받아 textarea에 채운다. drafting은 "생성 중" 표시용. + const [drafting, setDrafting] = useState(false); + const [draftNote, setDraftNote] = useState(''); function load() { // MentorPage와 같은 이유로 allSettled — 한쪽이 실패해도 다른 쪽은 보여 준다. @@ -93,6 +96,35 @@ export default function MentorCodingSection() { setSelectedId(submission.id); setFeedbackDraft(submission.feedback || ''); setError(''); + setDraftNote(''); + } + + // AI 초안 받기 — 로컬 LLM이 학생 코드를 보고 피드백 초안을 만든다. + // 학습 포인트: 이건 "판정"이 아니라 "초안"이다. textarea에 채워 넣기만 하고, + // 최종 저장은 멘토가 읽고 고친 뒤 기존 '피드백 저장' 버튼으로 한다. + // 로컬 모델은 느리므로(수십 초) 버튼을 잠그고 진행 중임을 알린다. + async function requestDraft() { + if (feedbackDraft.trim() && + !window.confirm('작성 중인 내용을 AI 초안으로 덮어쓸까요?')) { + return; + } + setDrafting(true); + setDraftNote(''); + setError(''); + try { + const res = await client.post(`/mentor/coding-submissions/${selectedId}/feedback-draft`); + if (res.data?.draft) { + setFeedbackDraft(res.data.draft); + setDraftNote('AI 초안입니다 — 반드시 확인하고 고쳐서 저장하세요.'); + } else { + // draft가 null: 모델 미설정이거나 응답 실패. 멘토는 손으로 쓰면 된다. + setDraftNote('초안을 만들지 못했어요(모델이 없거나 응답 실패). 직접 작성해 주세요.'); + } + } catch { + setDraftNote('초안 생성 중 문제가 생겼어요. 직접 작성해 주세요.'); + } finally { + setDrafting(false); + } } async function saveFeedback(e) { @@ -231,16 +263,30 @@ export default function MentorCodingSection() {
- +
+ + +