feat: 멘토 코딩 피드백 AI 초안 기능
멘토가 코딩 제출물에 피드백을 쓸 때, 로컬 LLM(gpt-oss:20b)이 초안을
만들어 준다. 최종 판정은 멘토가 한다 — LLM은 초안 작성자일 뿐이다.
- LlmFeedbackDrafter: Ollama /api/chat 호출 (TelegramNotifier 패턴 —
비활성 시 조용히, 예외 삼킴, 학습 포인트 주석)
- POST /api/mentor/coding-submissions/{id}/feedback-draft (초안만, 저장 안 함)
- 멘토 화면에 "AI 초안 받기" 버튼 → textarea 채움 → 멘토가 고쳐 저장
- llm.base-url 미설정 시 기능 자동 비활성 (모델 없어도 앱 정상 동작)
호출 규칙(/api/chat, num_ctx 16384, gpt-oss:20b)은 E:/llm 평가 근거:
qwen 계열은 async 버그를 "정상"이라 놓쳐 피드백에 위험, gpt-oss는 6/6.
This commit is contained in:
parent
169dcfba38
commit
c26207ee48
@ -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<CodingSubmission> allSubmissions() {
|
||||
|
||||
@ -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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
<form onSubmit={saveFeedback} style={{ marginTop: 12 }}>
|
||||
<div className="field">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<label>피드백</label>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
onClick={requestDraft}
|
||||
disabled={drafting || saving}
|
||||
title="로컬 AI가 초안을 만들어 줍니다. 확인하고 고쳐서 저장하세요."
|
||||
>
|
||||
{drafting ? '초안 생성 중… (수십 초)' : '✨ AI 초안 받기'}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={feedbackDraft}
|
||||
onChange={(e) => setFeedbackDraft(e.target.value)}
|
||||
placeholder="잘한 점과 다음에 시도해 볼 점을 구체적으로 적어 주세요."
|
||||
/>
|
||||
{draftNote && (
|
||||
<p className="empty" style={{ marginTop: 6 }}>{draftNote}</p>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}>
|
||||
<button className="btn btn-primary" type="submit" disabled={saving || drafting}>
|
||||
{saving ? '저장 중...' : '피드백 저장'}
|
||||
</button>
|
||||
<button
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user