feat: AI 학습 도우미 위젯 + 피드백 초안 영역 확대
All checks were successful
CI / backend-test (push) Successful in 1m18s
CI / frontend-build (push) Successful in 58s
CI / backend-dep-scan (push) Successful in 28s

- 우측 하단 플로팅 AI 학습 도우미. 학생이 막힐 때 질문하면 로컬 LLM
  (qwen3:8b, 빠른 대화형)이 정답 대신 힌트·설명으로 답한다.
  LlmAssistant + POST /api/assistant/ask + HelperWidget.
- 멘토 피드백 textarea min-height 200px — AI 초안 여러 문장이 한눈에.
This commit is contained in:
AWESOMEDEV 2026-07-23 12:55:50 +09:00
parent a209bb1d80
commit 8276870252
5 changed files with 357 additions and 0 deletions

View File

@ -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<String, Object> 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 "지금은 답을 만들지 못했어요. 잠시 후 다시 물어봐 주세요.";
}
}

View File

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

View File

@ -12,6 +12,7 @@ import { LEVEL_COURSES } from './levelCatalog';
import { TrackProvider, useTrack, TRACK_OPTIONS } from './trackContext'; import { TrackProvider, useTrack, TRACK_OPTIONS } from './trackContext';
import CourseShell from './components/CourseShell'; import CourseShell from './components/CourseShell';
import ScrollToTop from './components/ScrollToTop'; import ScrollToTop from './components/ScrollToTop';
import HelperWidget from './components/HelperWidget';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import SignupPage from './pages/SignupPage'; import SignupPage from './pages/SignupPage';
import FindAccountPage from './pages/FindAccountPage'; import FindAccountPage from './pages/FindAccountPage';
@ -165,6 +166,8 @@ function Layout() {
<Outlet /> <Outlet />
</Suspense> </Suspense>
</main> </main>
{/* 화면 우측 하단 AI 학습 도우미 — 로그인 후 모든 페이지에 뜬다. */}
<HelperWidget />
</> </>
); );
} }

View File

@ -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 && (
<div className="helper-panel" role="dialog" aria-label="AI 학습 도우미">
<div className="helper-head">
<span> AI 학습 도우미</span>
<button className="helper-x" onClick={() => setOpen(false)} aria-label="닫기">
<X size={18} strokeWidth={1.5} />
</button>
</div>
<div className="helper-body" ref={bodyRef}>
{msgs.length === 0 && (
<p className="helper-welcome">
막히는 부분이나 궁금한 개념을 물어보세요.<br />
정답을 통째로 주진 않지만, 스스로 풀도록 힌트를 드려요.
</p>
)}
{msgs.map((m, i) => (
<div key={i} className={`helper-msg helper-${m.role}`}>
{m.text}
</div>
))}
{loading && <div className="helper-msg helper-ai helper-thinking">생각 </div>}
</div>
<form className="helper-input" onSubmit={send}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="질문을 입력하세요"
aria-label="질문 입력"
/>
<button type="submit" className="helper-send" disabled={loading} aria-label="보내기">
<Send size={18} strokeWidth={1.5} />
</button>
</form>
</div>
)}
<button
className="helper-fab"
onClick={() => setOpen((o) => !o)}
aria-label={open ? 'AI 학습 도우미 닫기' : 'AI 학습 도우미 열기'}
>
{open ? <X size={24} strokeWidth={1.5} /> : <MessageCircle size={24} strokeWidth={1.5} />}
</button>
</>
);
}

View File

@ -378,6 +378,14 @@ a {
font: inherit; font: inherit;
} }
/* textarea는 여러 줄을 다루니 기본 높이를 넉넉히 주고, 사용자가 세로로 늘릴 있게 한다.
특히 AI 초안처럼 여러 문장이 들어오는 경우 한눈에 보이도록. */
.textarea {
min-height: 200px;
line-height: 1.6;
resize: vertical;
}
.input:focus, .textarea:focus { .input:focus, .textarea:focus {
outline: 2px solid var(--primary-soft); outline: 2px solid var(--primary-soft);
border-color: var(--primary); border-color: var(--primary);
@ -1467,3 +1475,115 @@ inline-code, .icode {
.doc-shadow-host { .doc-shadow-host {
display: block; 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; }