From 827687025271dd6eef7a6154c05be017b79b3a44 Mon Sep 17 00:00:00 2001 From: AWESOMEDEV Date: Thu, 23 Jul 2026 12:55:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=ED=95=99=EC=8A=B5=20=EB=8F=84?= =?UTF-8?q?=EC=9A=B0=EB=AF=B8=20=EC=9C=84=EC=A0=AF=20+=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EC=B4=88=EC=95=88=20=EC=98=81=EC=97=AD=20?= =?UTF-8?q?=ED=99=95=EB=8C=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 우측 하단 플로팅 AI 학습 도우미. 학생이 막힐 때 질문하면 로컬 LLM (qwen3:8b, 빠른 대화형)이 정답 대신 힌트·설명으로 답한다. LlmAssistant + POST /api/assistant/ask + HelperWidget. - 멘토 피드백 textarea min-height 200px — AI 초안 여러 문장이 한눈에. --- .../mirim/service/LlmAssistant.java | 113 +++++++++++++++++ .../mirim/web/AssistantController.java | 35 +++++ frontend/src/App.jsx | 3 + frontend/src/components/HelperWidget.jsx | 86 +++++++++++++ frontend/src/styles/global.css | 120 ++++++++++++++++++ 5 files changed, 357 insertions(+) create mode 100644 backend/src/main/java/dev/awesomedev/mirim/service/LlmAssistant.java create mode 100644 backend/src/main/java/dev/awesomedev/mirim/web/AssistantController.java create mode 100644 frontend/src/components/HelperWidget.jsx diff --git a/backend/src/main/java/dev/awesomedev/mirim/service/LlmAssistant.java b/backend/src/main/java/dev/awesomedev/mirim/service/LlmAssistant.java new file mode 100644 index 0000000..a7bacc4 --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/service/LlmAssistant.java @@ -0,0 +1,113 @@ +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; + +/** + * 이 파일이 하는 일: + * 화면 우측 하단 "AI 학습 도우미"의 두뇌. 학생 질문을 로컬 LLM에게 넘겨 답을 받아 온다. + * + * 학습 포인트 ① — 왜 gpt-oss가 아니라 qwen3:8b인가. + * 멘토 피드백 초안({@link LlmFeedbackDrafter})은 정확도가 생명이라 느려도 gpt-oss를 쓴다. + * 하지만 학습 도우미는 학생과 실시간으로 주고받는 대화라 "빠른 응답"이 더 중요하다. + * 평가 결과, 힌트·개념 설명 같은 대화형 태스크는 qwen3:8b(40 tok/s)로도 충분했다. + * "무엇이 중요한가"에 따라 같은 로컬 LLM이라도 모델을 다르게 고른다. + * + * 학습 포인트 ② — 부가 기능이 본 기능을 죽이면 안 된다({@link TelegramNotifier}와 같은 원칙). + * 모델이 없거나 느려도 학습 자체는 굴러가야 한다. 실패하면 예외를 던지지 않고 + * "지금은 도우미를 쓸 수 없어요" 안내 문구를 돌려준다. base-url이 비면 아예 꺼진다. + * + * 호출 규칙은 LlmFeedbackDrafter와 동일하다: /api/chat, num_ctx, non-stream. + */ +@Service +public class LlmAssistant { + + private static final Logger log = LoggerFactory.getLogger(LlmAssistant.class); + + private static final String SYSTEM = """ + 너는 어썸데브 학습 플랫폼의 AI 학습 도우미다. 프로그래밍을 배우는 수습생의 질문에 + 친절하고 정확하게 한국어로 답한다. 개념 설명은 충실히 하되, 지금 풀고 있는 코딩 문제의 + 완성된 정답 코드를 통째로 주지는 마라 — 학생이 스스로 풀도록 방향과 힌트를 준다. + 답은 간결하게, 꼭 필요하면 짧은 예시 코드만 곁들인다."""; + + private final String baseUrl; + private final String model; + private final int numCtx; + private final RestClient restClient; + + public LlmAssistant( + @Value("${llm.base-url:}") String baseUrl, + @Value("${llm.assistant-model:qwen3:8b}") String model, + @Value("${llm.num-ctx:16384}") int numCtx) { + this.baseUrl = baseUrl; + this.model = model; + this.numCtx = numCtx; + this.restClient = RestClient.builder() + .baseUrl(baseUrl.isBlank() ? "http://127.0.0.1:11434" : baseUrl) + .requestFactory(timeouts(Duration.ofSeconds(5), Duration.ofMinutes(3))) + .build(); + if (!isEnabled()) { + log.info("AI 학습 도우미 비활성 상태입니다 (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; + } + + public boolean isEnabled() { + return !baseUrl.isBlank(); + } + + /** 학생 질문에 대한 답변. 실패하거나 비활성이면 안내 문구를 돌려준다(예외 안 던짐). */ + public String ask(String question) { + if (question == null || question.isBlank()) { + return "무엇이 궁금한지 적어 주세요."; + } + if (!isEnabled()) { + return "지금은 학습 도우미를 쓸 수 없어요. 잠시 후 다시 시도해 주세요."; + } + try { + @SuppressWarnings("unchecked") + Map body = restClient.post() + .uri("/api/chat") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of( + "model", model, + "stream", false, + "think", false, + "messages", List.of( + Map.of("role", "system", "content", SYSTEM), + Map.of("role", "user", "content", question)), + "options", Map.of("num_ctx", numCtx, "temperature", 0.3))) + .retrieve() + .body(Map.class); + + if (body == null) return fallback(); + Object message = body.get("message"); + if (!(message instanceof Map m)) return fallback(); + Object content = m.get("content"); + String text = content == null ? "" : content.toString().trim(); + return text.isBlank() ? fallback() : text; + } catch (Exception e) { + log.warn("AI 학습 도우미 응답 실패(무시하고 계속): {}", e.getClass().getSimpleName()); + return fallback(); + } + } + + private String fallback() { + return "지금은 답을 만들지 못했어요. 잠시 후 다시 물어봐 주세요."; + } +} diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/AssistantController.java b/backend/src/main/java/dev/awesomedev/mirim/web/AssistantController.java new file mode 100644 index 0000000..7dcd679 --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/web/AssistantController.java @@ -0,0 +1,35 @@ +package dev.awesomedev.mirim.web; + +import dev.awesomedev.mirim.service.LlmAssistant; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 이 파일이 하는 일: + * 화면 우측 하단 "AI 학습 도우미"의 질문을 받는 API. 로그인한 사용자만 쓴다 + * (SecurityConfig가 /api/** 를 인증 뒤로 둔다). 실제 답은 {@link LlmAssistant}가 만든다. + */ +@RestController +@RequestMapping("/api/assistant") +public class AssistantController { + + private final LlmAssistant assistant; + + public AssistantController(LlmAssistant assistant) { + this.assistant = assistant; + } + + public record AskRequest(String question) { + } + + public record AskResponse(String answer, boolean enabled) { + } + + /** POST /api/assistant/ask — 질문 하나에 답 하나. 대화 맥락은 아직 안 쌓는다(단발). */ + @PostMapping("/ask") + public AskResponse ask(@RequestBody AskRequest req) { + return new AskResponse(assistant.ask(req.question()), assistant.isEnabled()); + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5a00ae2..bb59d3d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import { LEVEL_COURSES } from './levelCatalog'; import { TrackProvider, useTrack, TRACK_OPTIONS } from './trackContext'; import CourseShell from './components/CourseShell'; import ScrollToTop from './components/ScrollToTop'; +import HelperWidget from './components/HelperWidget'; import LoginPage from './pages/LoginPage'; import SignupPage from './pages/SignupPage'; import FindAccountPage from './pages/FindAccountPage'; @@ -165,6 +166,8 @@ function Layout() { + {/* 화면 우측 하단 AI 학습 도우미 — 로그인 후 모든 페이지에 뜬다. */} + ); } diff --git a/frontend/src/components/HelperWidget.jsx b/frontend/src/components/HelperWidget.jsx new file mode 100644 index 0000000..c078145 --- /dev/null +++ b/frontend/src/components/HelperWidget.jsx @@ -0,0 +1,86 @@ +// 이 파일이 하는 일: 화면 우측 하단에 떠 있는 "AI 학습 도우미" 챗 위젯. +// 학생이 막힐 때 질문하면 로컬 LLM(qwen3:8b)이 힌트·설명으로 답한다. +// +// 학습 포인트: 플로팅 버튼(FAB)은 position: fixed로 스크롤과 무관하게 화면에 고정한다. +// 패널은 버튼 위로 펼쳐지고, 열림 상태(open)만 컴포넌트가 기억하면 된다. +// 대화는 아직 "단발"이다 — 한 질문에 한 답. 맥락을 쌓으려면 이전 메시지를 함께 보내야 한다. +import { useState, useRef, useEffect } from 'react'; +import { MessageCircle, X, Send } from 'lucide-react'; +import client from '../api/client'; + +export default function HelperWidget() { + const [open, setOpen] = useState(false); + const [msgs, setMsgs] = useState([]); // { role: 'user' | 'ai', text } + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + const bodyRef = useRef(null); + + // 새 메시지가 오면 대화창을 맨 아래로 스크롤한다. + useEffect(() => { + if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; + }, [msgs, loading]); + + async function send(e) { + e.preventDefault(); + const q = input.trim(); + if (!q || loading) return; + setMsgs((m) => [...m, { role: 'user', text: q }]); + setInput(''); + setLoading(true); + try { + const res = await client.post('/assistant/ask', { question: q }); + setMsgs((m) => [...m, { role: 'ai', text: res.data.answer }]); + } catch { + setMsgs((m) => [...m, { role: 'ai', text: '문제가 생겼어요. 잠시 후 다시 물어봐 주세요.' }]); + } finally { + setLoading(false); + } + } + + return ( + <> + {open && ( +
+
+ ✨ AI 학습 도우미 + +
+
+ {msgs.length === 0 && ( +

+ 막히는 부분이나 궁금한 개념을 물어보세요.
+ 정답을 통째로 주진 않지만, 스스로 풀도록 힌트를 드려요. +

+ )} + {msgs.map((m, i) => ( +
+ {m.text} +
+ ))} + {loading &&
생각 중…
} +
+
+ setInput(e.target.value)} + placeholder="질문을 입력하세요" + aria-label="질문 입력" + /> + +
+
+ )} + + + ); +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index e87203c..1458dad 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -378,6 +378,14 @@ a { font: inherit; } +/* textarea는 여러 줄을 다루니 기본 높이를 넉넉히 주고, 사용자가 세로로 늘릴 수 있게 한다. + 특히 AI 초안처럼 여러 문장이 들어오는 경우 한눈에 보이도록. */ +.textarea { + min-height: 200px; + line-height: 1.6; + resize: vertical; +} + .input:focus, .textarea:focus { outline: 2px solid var(--primary-soft); border-color: var(--primary); @@ -1467,3 +1475,115 @@ inline-code, .icode { .doc-shadow-host { display: block; } + +/* ── AI 학습 도우미 (우측 하단 플로팅 챗) ───────────────────────────── */ +.helper-fab { + position: fixed; + right: 24px; + bottom: 24px; + width: 56px; + height: 56px; + border-radius: 50%; + border: none; + background: var(--primary); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18); + z-index: 1000; +} +.helper-fab:hover { filter: brightness(1.06); } + +.helper-panel { + position: fixed; + right: 24px; + bottom: 92px; + width: 360px; + max-width: calc(100vw - 32px); + height: 480px; + max-height: calc(100vh - 140px); + background: var(--bg); + border: 1px solid var(--line); + border-radius: 16px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 1000; +} +.helper-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--line); + font-weight: 600; +} +.helper-x { + border: none; + background: none; + cursor: pointer; + color: var(--muted); + display: flex; +} +.helper-body { + flex: 1; + overflow-y: auto; + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 10px; +} +.helper-welcome { + color: var(--muted); + font-size: 14px; + line-height: 1.6; +} +.helper-msg { + padding: 10px 12px; + border-radius: 12px; + font-size: 14px; + line-height: 1.6; + white-space: pre-wrap; + max-width: 85%; +} +.helper-user { + align-self: flex-end; + background: var(--primary); + color: #fff; +} +.helper-ai { + align-self: flex-start; + background: var(--primary-soft); + color: var(--ink); +} +.helper-thinking { color: var(--muted); } +.helper-input { + display: flex; + gap: 8px; + padding: 12px; + border-top: 1px solid var(--line); +} +.helper-input input { + flex: 1; + min-width: 0; + padding: 9px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--bg); + color: var(--ink); + font: inherit; +} +.helper-send { + border: none; + background: var(--primary); + color: #fff; + border-radius: 10px; + padding: 0 12px; + cursor: pointer; + display: flex; + align-items: center; +} +.helper-send:disabled { opacity: 0.5; cursor: default; }