feat: 멘토 코딩 피드백 AI 초안 기능
All checks were successful
CI / backend-test (push) Successful in 1m16s
CI / frontend-build (push) Successful in 51s
CI / backend-dep-scan (push) Successful in 28s

멘토가 코딩 제출물에 피드백을 쓸 때, 로컬 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:
AWESOMEDEV 2026-07-23 10:58:45 +09:00
parent 169dcfba38
commit c26207ee48
6 changed files with 231 additions and 4 deletions

View File

@ -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() {

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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:

View File

@ -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");
}

View File

@ -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">
<label>피드백</label>
<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