Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
# CS Study Platform

CS 공부를 웹 사이트, 카테고리별 학습, AI 채점, Slack 리마인드 봇을 제공합니다.
CS 공부를 웹 사이트, 카테고리별 학습, AI 채점, AI 챗봇, Slack 리마인드 봇을 제공합니다.
<img width="500" height="400" alt="image" src="https://github.com/user-attachments/assets/bde6a38e-df84-4148-8c40-be3f9eb216f1" />

## 주요 기능

### 웹 플랫폼
- **카테고리별 학습** - DB, 자료구조, 운영체제, 네트워크, 알고리즘, 소프트웨어공학, Spring, Java 등 11개 카테고리
- **질문 & 답변** - 마크다운 기반 CS 면접 질문과 모범 답안
- **질문 & 답변** - 마크다운 기반 CS 면접 질문과 모범 답안 (109문항)
- **AI 채점** - 직접 답변을 작성하면 키워드 기반으로 채점 및 피드백
- **카오스 고양이 AI 챗봇** - Gemini 2.0 Flash 기반 CS 면접 Q&A 챗봇. DB의 학습 자료를 컨텍스트로 활용하여 정확한 답변 제공
- **학습 통계** - 진행률, 카테고리별 학습 현황, 점수 분포 등
- **검색** - 질문 제목/내용 통합 검색
- **북마크** - 중요한 질문 저장
- **면접 연습** - 웹캠/마이크를 활용한 실전 면접 연습
- **오프라인 대응** - 백엔드 서버가 다운되어도 하드코딩된 콘텐츠로 학습 가능

### Slack 리마인드 봇
- **하루 2회 알림** - 오전 8:20 / 오후 6:10 (KST)
Expand All @@ -37,6 +40,7 @@ CS 공부를 웹 사이트, 카테고리별 학습, AI 채점, Slack 리마인
| **Frontend** | React 19, Vite 8, Axios |
| **Backend** | Spring Boot 3.2, Spring Data JPA |
| **Database** | PostgreSQL |
| **AI** | Google Gemini 2.0 Flash (무료) |
| **배포** | Vercel (Frontend), Render (Backend + DB) |
| **알림** | Slack Incoming Webhooks |

Expand All @@ -46,18 +50,19 @@ CS 공부를 웹 사이트, 카테고리별 학습, AI 채점, Slack 리마인
cs-study-platform/
├── frontend/ # React + Vite 프론트엔드
│ └── src/
│ ├── pages/ # Home, Category, Question, Stats
│ ├── components/# Layout
│ ├── pages/ # Home, Category, Question, Stats, InterviewPractice
│ ├── components/# Layout, ChaosChat (AI 챗봇)
│ ├── hardcodedData.js # 오프라인 fallback 데이터
│ └── api.js # Axios 설정
├── backend/ # Spring Boot 백엔드
│ └── src/main/java/com/csstudy/backend/
│ ├── config/ # CORS, 캐시, 데이터 초기화
│ ├── controller/# REST API 컨트롤러
│ ├── service/ # 비즈니스 로직, Slack 서비스
│ ├── controller/# REST API + Chat 컨트롤러
│ ├── service/ # 비즈니스 로직, ChatService, Slack 서비스
│ ├── entity/ # JPA 엔티티
│ ├── repository/# Spring Data JPA
│ └── scheduler/ # Slack 스케줄러
├── content/ # 마크다운 CS 질문 콘텐츠
├── content/ # 마크다운 CS 질문 콘텐츠 (109문항)
├── review/ # 복습용 개념 정리 노트
└── render.yaml # Render 배포 설정
```
Expand All @@ -77,7 +82,14 @@ npm install
npm run dev
```

환경변수 설정:
```
VITE_API_URL=http://localhost:8080/api/v1
```
### 환경변수

| 변수 | 설명 | 필수 |
|------|------|------|
| `VITE_API_URL` | 백엔드 API URL (프론트엔드) | X (기본값 있음) |
| `GEMINI_API_KEY` | Google Gemini API 키 (백엔드) | X (없으면 fallback) |
| `JDBC_DATABASE_URL` | PostgreSQL 접속 URL | O |
| `SLACK_WEBHOOK_URL` | Slack 웹훅 URL | X |
| `SLACK_ENABLED` | Slack 알림 활성화 | X (기본 false) |

Gemini API 키는 [Google AI Studio](https://aistudio.google.com)에서 무료로 발급받을 수 있습니다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.csstudy.backend.controller;

import com.csstudy.backend.service.ChatService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/v1/chat")
@RequiredArgsConstructor
public class ChatController {

private final ChatService chatService;

@PostMapping
public ResponseEntity<Map<String, Object>> chat(@RequestBody Map<String, Object> request) {
String message = (String) request.get("message");
List<Map<String, String>> history = (List<Map<String, String>>) request.get("history");

if (message == null || message.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "메시지를 입력해주세요"));
}

Map<String, Object> result = chatService.chat(message.trim(), history);
return ResponseEntity.ok(result);
}
}
197 changes: 197 additions & 0 deletions backend/src/main/java/com/csstudy/backend/service/ChatService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package com.csstudy.backend.service;

import com.csstudy.backend.entity.Question;
import com.csstudy.backend.repository.QuestionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.*;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatService {

private final QuestionRepository questionRepository;
private final RestTemplate restTemplate = new RestTemplate();

@Value("${gemini.api-key:}")
private String apiKey;

private static final String SYSTEM_PROMPT = """
너는 '카오스 고양이'야. CS 면접 준비를 도와주는 귀여운 고양이 AI 조교야.
말투는 반말이고 고양이답게 가끔 "냥", "먀" 같은 표현을 섞어.
하지만 CS 지식은 정확하고 깊이 있게 답변해야 해.
답변은 간결하되 핵심을 놓치지 마.
코드 예시가 필요하면 짧게 포함해.
모르는 건 솔직하게 모른다고 해.

너는 아래 CS 면접 학습 자료를 기반으로 답변해야 해.
사용자의 질문과 관련된 참고 자료가 제공되면, 그 내용을 바탕으로 정확하게 답변해.
참고 자료가 없는 주제라도 CS 관련이면 네 지식으로 최대한 답변해줘.
CS와 관련 없는 질문이면 "나는 CS 전문 고양이라 그건 잘 모르겠다냥~" 이라고 해.
""";

public Map<String, Object> chat(String userMessage, List<Map<String, String>> history) {
if (apiKey == null || apiKey.isBlank()) {
return fallbackChat(userMessage);
}

try {
return callGemini(userMessage, history);
} catch (Exception e) {
log.error("Gemini API error, falling back", e);
return fallbackChat(userMessage);
}
}

private List<Question> findRelevantQuestions(String userMessage) {
String lower = userMessage.toLowerCase();
List<Question> all = questionRepository.findAll();

// 1차: 전체 메시지로 매칭
List<Question> matched = all.stream()
.filter(q -> q.getTitle().toLowerCase().contains(lower)
|| (q.getTags() != null && q.getTags().toLowerCase().contains(lower)))
.limit(5)
.toList();

if (!matched.isEmpty()) return matched;

// 2차: 키워드 단위로 매칭
String[] keywords = lower.split("\\s+");
matched = all.stream()
.filter(q -> Arrays.stream(keywords).anyMatch(k ->
k.length() >= 2 && (
q.getTitle().toLowerCase().contains(k)
|| (q.getTags() != null && q.getTags().toLowerCase().contains(k))
|| (q.getContent() != null && q.getContent().toLowerCase().contains(k))
|| (q.getAnswer() != null && q.getAnswer().toLowerCase().contains(k))
)))
.limit(5)
.toList();

return matched;
}

private String buildContextFromQuestions(List<Question> questions) {
if (questions.isEmpty()) return "";

StringBuilder sb = new StringBuilder();
sb.append("\n\n[참고 자료]\n");
for (Question q : questions) {
sb.append("---\n");
sb.append("제목: ").append(q.getTitle()).append("\n");
if (q.getTags() != null && !q.getTags().isBlank()) {
sb.append("키워드: ").append(q.getTags()).append("\n");
}
if (q.getContent() != null) {
sb.append("질문: ").append(q.getContent()).append("\n");
}
if (q.getAnswer() != null) {
String answer = q.getAnswer();
if (answer.length() > 800) {
answer = answer.substring(0, 800) + "...";
}
sb.append("모범답변: ").append(answer).append("\n");
}
}
return sb.toString();
}

private Map<String, Object> callGemini(String userMessage, List<Map<String, String>> history) {
String url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=" + apiKey;

// Find relevant CS content for context
List<Question> relevant = findRelevantQuestions(userMessage);
String context = buildContextFromQuestions(relevant);
String fullSystemPrompt = SYSTEM_PROMPT + context;

// Build contents array with history
List<Map<String, Object>> contents = new ArrayList<>();

if (history != null) {
for (Map<String, String> msg : history) {
String role = "user".equals(msg.get("role")) ? "user" : "model";
contents.add(Map.of(
"role", role,
"parts", List.of(Map.of("text", msg.get("content")))
));
}
}

// Add current user message
contents.add(Map.of(
"role", "user",
"parts", List.of(Map.of("text", userMessage))
));

Map<String, Object> body = new LinkedHashMap<>();
body.put("system_instruction", Map.of(
"parts", List.of(Map.of("text", fullSystemPrompt))
));
body.put("contents", contents);
body.put("generationConfig", Map.of(
"maxOutputTokens", 1024,
"temperature", 0.7
));

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);

ResponseEntity<Map> response = restTemplate.exchange(
url,
HttpMethod.POST,
new HttpEntity<>(body, headers),
Map.class
);

Map responseBody = response.getBody();
if (responseBody == null) {
return fallbackChat(userMessage);
}

List<Map<String, Object>> candidates = (List<Map<String, Object>>) responseBody.get("candidates");
if (candidates == null || candidates.isEmpty()) {
return fallbackChat(userMessage);
}

Map<String, Object> content = (Map<String, Object>) candidates.get(0).get("content");
List<Map<String, Object>> parts = (List<Map<String, Object>>) content.get("parts");
String reply = parts.stream()
.map(p -> (String) p.get("text"))
.collect(Collectors.joining());

return Map.of("reply", reply, "source", "ai");
}

private Map<String, Object> fallbackChat(String userMessage) {
List<Question> matched = findRelevantQuestions(userMessage);

if (matched.isEmpty()) {
return Map.of(
"reply", "냥... 그 질문은 아직 잘 모르겠다냥! 😿 다른 CS 키워드로 물어봐줘!",
"source", "fallback"
);
}

StringBuilder sb = new StringBuilder();
sb.append("냥! 관련 내용을 찾았다냥~ 🐱\n\n");
for (Question q : matched) {
sb.append("**").append(q.getTitle()).append("**\n");
String answer = q.getAnswer();
if (answer != null && answer.length() > 300) {
answer = answer.substring(0, 300) + "...";
}
sb.append(answer).append("\n\n");
}
sb.append("더 자세한 내용은 해당 질문 페이지에서 확인해봐냥!");

return Map.of("reply", sb.toString(), "source", "fallback");
}
}
3 changes: 3 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ slack:

app:
base-url: ${APP_BASE_URL:http://localhost:3000}

gemini:
api-key: ${GEMINI_API_KEY:}
Loading