Implement all core features: Knowledge pipeline, RAG chat, Todos, Habits, Study Cards, Tags, Dashboard
- Google OAuth authentication with callback flow - Knowledge ingest pipeline (TEXT/WEB/YOUTUBE → chunking → categorization → embedding) - OCI GenAI integration (chat, embeddings) with multi-model support - Semantic search via Oracle VECTOR_DISTANCE - RAG-based AI chat with source attribution - Todos with subtasks, filters, and priority levels - Habits with daily check-in, streak tracking, and color customization - Study Cards with SM-2 spaced repetition and LLM auto-generation - Tags system with knowledge item mapping - Dashboard with live data from all modules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,10 +56,12 @@
|
||||
<dependency>
|
||||
<groupId>com.oracle.database.security</groupId>
|
||||
<artifactId>osdt_cert</artifactId>
|
||||
<version>21.18.0.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.oracle.database.security</groupId>
|
||||
<artifactId>osdt_core</artifactId>
|
||||
<version>21.18.0.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT -->
|
||||
@@ -81,6 +83,13 @@
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Google Auth Library -->
|
||||
<dependency>
|
||||
<groupId>com.google.auth</groupId>
|
||||
<artifactId>google-auth-library-oauth2-http</artifactId>
|
||||
<version>1.30.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package com.sundol.controller;
|
||||
|
||||
import com.sundol.dto.LoginRequest;
|
||||
import com.sundol.dto.LoginResponse;
|
||||
import com.sundol.dto.RefreshRequest;
|
||||
import com.sundol.service.AuthService;
|
||||
import org.springframework.http.HttpCookie;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.http.server.reactive.ServerHttpResponse;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
@@ -19,19 +25,63 @@ public class AuthController {
|
||||
}
|
||||
|
||||
@PostMapping("/google")
|
||||
public Mono<ResponseEntity<LoginResponse>> googleLogin(@RequestBody LoginRequest request) {
|
||||
return authService.googleLogin(request.idToken())
|
||||
.map(ResponseEntity::ok);
|
||||
public Mono<ResponseEntity<LoginResponse>> googleLogin(
|
||||
@RequestBody Map<String, String> body,
|
||||
ServerHttpResponse response) {
|
||||
String code = body.get("code");
|
||||
return authService.googleLogin(code)
|
||||
.map(loginResponse -> {
|
||||
// Set refresh token as HttpOnly cookie
|
||||
ResponseCookie cookie = ResponseCookie.from("refreshToken", loginResponse.refreshToken())
|
||||
.httpOnly(true)
|
||||
.secure(true)
|
||||
.path("/api/auth")
|
||||
.maxAge(Duration.ofDays(30))
|
||||
.sameSite("Strict")
|
||||
.build();
|
||||
response.addCookie(cookie);
|
||||
return ResponseEntity.ok(loginResponse);
|
||||
});
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
public Mono<ResponseEntity<LoginResponse>> refresh(@RequestBody RefreshRequest request) {
|
||||
return authService.refresh(request.refreshToken())
|
||||
.map(ResponseEntity::ok);
|
||||
public Mono<ResponseEntity<LoginResponse>> refresh(ServerHttpRequest request, ServerHttpResponse response) {
|
||||
HttpCookie cookie = request.getCookies().getFirst("refreshToken");
|
||||
String refreshToken = cookie != null ? cookie.getValue() : null;
|
||||
|
||||
if (refreshToken == null) {
|
||||
return Mono.just(ResponseEntity.status(401).build());
|
||||
}
|
||||
|
||||
return authService.refresh(refreshToken)
|
||||
.map(loginResponse -> {
|
||||
// Rotate cookie
|
||||
ResponseCookie newCookie = ResponseCookie.from("refreshToken", loginResponse.refreshToken())
|
||||
.httpOnly(true)
|
||||
.secure(true)
|
||||
.path("/api/auth")
|
||||
.maxAge(Duration.ofDays(30))
|
||||
.sameSite("Strict")
|
||||
.build();
|
||||
response.addCookie(newCookie);
|
||||
return ResponseEntity.ok(loginResponse);
|
||||
});
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public Mono<ResponseEntity<Void>> logout(@RequestAttribute("userId") String userId) {
|
||||
public Mono<ResponseEntity<Void>> logout(
|
||||
@AuthenticationPrincipal String userId,
|
||||
ServerHttpResponse response) {
|
||||
// Clear cookie
|
||||
ResponseCookie cookie = ResponseCookie.from("refreshToken", "")
|
||||
.httpOnly(true)
|
||||
.secure(true)
|
||||
.path("/api/auth")
|
||||
.maxAge(0)
|
||||
.sameSite("Strict")
|
||||
.build();
|
||||
response.addCookie(cookie);
|
||||
|
||||
return authService.logout(userId)
|
||||
.then(Mono.just(ResponseEntity.ok().<Void>build()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sundol.controller;
|
||||
|
||||
import com.sundol.service.OciGenAiService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/models")
|
||||
public class ModelController {
|
||||
|
||||
private final OciGenAiService genAiService;
|
||||
|
||||
public ModelController(OciGenAiService genAiService) {
|
||||
this.genAiService = genAiService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> listModels() {
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"models", OciGenAiService.AVAILABLE_MODELS,
|
||||
"defaultModel", genAiService.getDefaultModel(),
|
||||
"configured", genAiService.isConfigured()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
package com.sundol.dto;
|
||||
|
||||
public record IngestRequest(String type, String url, String title, String rawText) {}
|
||||
public record IngestRequest(String type, String url, String title, String rawText, String modelId) {}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.sundol.repository;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class CategoryRepository {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public CategoryRepository(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* full_path로 카테고리 조회. 없으면 null.
|
||||
*/
|
||||
public Map<String, Object> findByUserAndPath(String userId, String fullPath) {
|
||||
var results = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, name, RAWTOHEX(parent_id) AS parent_id, depth, full_path " +
|
||||
"FROM categories WHERE user_id = HEXTORAW(?) AND full_path = ?",
|
||||
userId, fullPath
|
||||
);
|
||||
return results.isEmpty() ? null : results.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 생성. 이미 존재하면 기존 ID 반환.
|
||||
* full_path 예: "건강/운동/웨이트트레이닝"
|
||||
*/
|
||||
public String findOrCreate(String userId, String fullPath) {
|
||||
Map<String, Object> existing = findByUserAndPath(userId, fullPath);
|
||||
if (existing != null) {
|
||||
return (String) existing.get("ID");
|
||||
}
|
||||
|
||||
// 경로 파싱
|
||||
String[] parts = fullPath.split("/");
|
||||
int depth = parts.length;
|
||||
String name = parts[parts.length - 1];
|
||||
|
||||
// 부모 카테고리 확보 (재귀)
|
||||
String parentId = null;
|
||||
if (depth > 1) {
|
||||
String parentPath = fullPath.substring(0, fullPath.lastIndexOf('/'));
|
||||
parentId = findOrCreate(userId, parentPath);
|
||||
}
|
||||
|
||||
// 삽입
|
||||
if (parentId != null) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO categories (id, user_id, name, parent_id, depth, full_path, created_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, HEXTORAW(?), ?, ?, SYSTIMESTAMP)",
|
||||
userId, name, parentId, depth, fullPath
|
||||
);
|
||||
} else {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO categories (id, user_id, name, parent_id, depth, full_path, created_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, NULL, ?, ?, SYSTIMESTAMP)",
|
||||
userId, name, depth, fullPath
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object> created = findByUserAndPath(userId, fullPath);
|
||||
return (String) created.get("ID");
|
||||
}
|
||||
|
||||
/**
|
||||
* knowledge_item에 카테고리 매핑
|
||||
*/
|
||||
public void linkCategory(String knowledgeItemId, String categoryId) {
|
||||
// 중복 방지
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM knowledge_item_categories WHERE knowledge_item_id = HEXTORAW(?) AND category_id = HEXTORAW(?)",
|
||||
Integer.class, knowledgeItemId, categoryId
|
||||
);
|
||||
if (count != null && count > 0) return;
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO knowledge_item_categories (knowledge_item_id, category_id) VALUES (HEXTORAW(?), HEXTORAW(?))",
|
||||
knowledgeItemId, categoryId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* knowledge_item의 카테고리 목록 조회
|
||||
*/
|
||||
public List<Map<String, Object>> findByKnowledgeItemId(String knowledgeItemId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(c.id) AS id, c.name, c.depth, c.full_path " +
|
||||
"FROM categories c " +
|
||||
"JOIN knowledge_item_categories kic ON kic.category_id = c.id " +
|
||||
"WHERE kic.knowledge_item_id = HEXTORAW(?) " +
|
||||
"ORDER BY c.full_path",
|
||||
knowledgeItemId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자의 전체 카테고리 트리 조회
|
||||
*/
|
||||
public List<Map<String, Object>> findAllByUser(String userId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, name, RAWTOHEX(parent_id) AS parent_id, depth, full_path " +
|
||||
"FROM categories WHERE user_id = HEXTORAW(?) ORDER BY full_path",
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* knowledge_item의 카테고리 매핑 전체 삭제
|
||||
*/
|
||||
public void unlinkAll(String knowledgeItemId) {
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM knowledge_item_categories WHERE knowledge_item_id = HEXTORAW(?)",
|
||||
knowledgeItemId
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package com.sundol.repository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class ChatRepository {
|
||||
|
||||
@@ -12,5 +15,75 @@ public class ChatRepository {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
// TODO: CRUD for chat_sessions, chat_messages
|
||||
public String createSession(String userId, String title) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO chat_sessions (id, user_id, title, created_at, updated_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||
userId, title
|
||||
);
|
||||
var result = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id FROM chat_sessions WHERE user_id = HEXTORAW(?) " +
|
||||
"ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||
userId
|
||||
);
|
||||
return (String) result.get(0).get("ID");
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listSessions(String userId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, title, created_at, updated_at " +
|
||||
"FROM chat_sessions WHERE user_id = HEXTORAW(?) ORDER BY updated_at DESC",
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
public Map<String, Object> findSession(String userId, String sessionId) {
|
||||
var results = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, title, created_at, updated_at " +
|
||||
"FROM chat_sessions WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||
sessionId, userId
|
||||
);
|
||||
return results.isEmpty() ? null : results.get(0);
|
||||
}
|
||||
|
||||
public void updateSessionTitle(String sessionId, String title) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE chat_sessions SET title = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
title, sessionId
|
||||
);
|
||||
}
|
||||
|
||||
public void touchSession(String sessionId) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE chat_sessions SET updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
|
||||
public void deleteSession(String userId, String sessionId) {
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM chat_messages WHERE session_id = (SELECT id FROM chat_sessions WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?))",
|
||||
sessionId, userId
|
||||
);
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM chat_sessions WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||
sessionId, userId
|
||||
);
|
||||
}
|
||||
|
||||
public void insertMessage(String sessionId, String role, String content, String sourceChunksJson, Integer tokensUsed) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO chat_messages (id, session_id, role, content, source_chunks, tokens_used, created_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, SYSTIMESTAMP)",
|
||||
sessionId, role, content, sourceChunksJson, tokensUsed
|
||||
);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getMessages(String sessionId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, role, content, source_chunks, tokens_used, created_at " +
|
||||
"FROM chat_messages WHERE session_id = HEXTORAW(?) ORDER BY created_at ASC",
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.sundol.repository;
|
||||
|
||||
import oracle.jdbc.OracleType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class ChunkEmbeddingRepository {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public ChunkEmbeddingRepository(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
public void upsertEmbedding(String chunkId, String modelId, float[] embedding) {
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM knowledge_chunk_embeddings WHERE chunk_id = HEXTORAW(?) AND model_id = ?",
|
||||
chunkId, modelId
|
||||
);
|
||||
jdbcTemplate.update(con -> {
|
||||
PreparedStatement ps = con.prepareStatement(
|
||||
"INSERT INTO knowledge_chunk_embeddings (id, chunk_id, model_id, embedding, created_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, SYSTIMESTAMP)"
|
||||
);
|
||||
ps.setString(1, chunkId);
|
||||
ps.setString(2, modelId);
|
||||
ps.setObject(3, embedding, OracleType.VECTOR);
|
||||
return ps;
|
||||
});
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findByChunkId(String chunkId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, model_id, created_at " +
|
||||
"FROM knowledge_chunk_embeddings WHERE chunk_id = HEXTORAW(?)",
|
||||
chunkId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 벡터 유사도 검색. 사용자의 knowledge 항목 중 READY 상태인 것만 대상.
|
||||
*/
|
||||
public List<Map<String, Object>> searchSimilar(String userId, float[] queryEmbedding, int topK) {
|
||||
return jdbcTemplate.query(con -> {
|
||||
PreparedStatement ps = con.prepareStatement(
|
||||
"SELECT RAWTOHEX(c.id) AS chunk_id, c.content, c.chunk_index, c.token_count, " +
|
||||
" RAWTOHEX(ki.id) AS knowledge_item_id, ki.title, ki.type, ki.source_url, " +
|
||||
" VECTOR_DISTANCE(e.embedding, ?, COSINE) AS distance " +
|
||||
"FROM knowledge_chunk_embeddings e " +
|
||||
"JOIN knowledge_chunks c ON c.id = e.chunk_id " +
|
||||
"JOIN knowledge_items ki ON ki.id = c.knowledge_item_id " +
|
||||
"WHERE ki.user_id = HEXTORAW(?) AND ki.status = 'READY' " +
|
||||
"ORDER BY distance ASC " +
|
||||
"FETCH FIRST ? ROWS ONLY"
|
||||
);
|
||||
ps.setObject(1, queryEmbedding, OracleType.VECTOR);
|
||||
ps.setString(2, userId);
|
||||
ps.setInt(3, topK);
|
||||
return ps;
|
||||
}, (rs, rowNum) -> {
|
||||
Map<String, Object> row = new java.util.HashMap<>();
|
||||
row.put("CHUNK_ID", rs.getString("chunk_id"));
|
||||
row.put("CONTENT", rs.getString("content"));
|
||||
row.put("CHUNK_INDEX", rs.getInt("chunk_index"));
|
||||
row.put("TOKEN_COUNT", rs.getInt("token_count"));
|
||||
row.put("KNOWLEDGE_ITEM_ID", rs.getString("knowledge_item_id"));
|
||||
row.put("TITLE", rs.getString("title"));
|
||||
row.put("TYPE", rs.getString("type"));
|
||||
row.put("SOURCE_URL", rs.getString("source_url"));
|
||||
row.put("DISTANCE", rs.getDouble("distance"));
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
public void deleteByKnowledgeItemId(String knowledgeItemId) {
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM knowledge_chunk_embeddings WHERE chunk_id IN " +
|
||||
"(SELECT id FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?))",
|
||||
knowledgeItemId
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package com.sundol.repository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class HabitRepository {
|
||||
|
||||
@@ -12,5 +15,137 @@ public class HabitRepository {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
// TODO: CRUD for habits, habit_logs
|
||||
public String insert(String userId, String name, String description, String habitType, String targetDays, String color) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO habits (id, user_id, name, description, habit_type, target_days, color, streak_current, streak_best, is_active, created_at, updated_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, ?, 0, 0, 1, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||
userId, name, description, habitType, targetDays, color
|
||||
);
|
||||
var result = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id FROM habits WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||
userId
|
||||
);
|
||||
return (String) result.get(0).get("ID");
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> list(String userId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, name, description, habit_type, target_days, color, " +
|
||||
"streak_current, streak_best, is_active, created_at, updated_at " +
|
||||
"FROM habits WHERE user_id = HEXTORAW(?) AND is_active = 1 ORDER BY created_at ASC",
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
public Map<String, Object> findById(String userId, String id) {
|
||||
var results = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, name, description, habit_type, target_days, color, " +
|
||||
"streak_current, streak_best, is_active, created_at, updated_at " +
|
||||
"FROM habits WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||
id, userId
|
||||
);
|
||||
return results.isEmpty() ? null : results.get(0);
|
||||
}
|
||||
|
||||
public void updateName(String id, String name) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE habits SET name = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
name, id
|
||||
);
|
||||
}
|
||||
|
||||
public void updateStreak(String id, int current, int best) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE habits SET streak_current = ?, streak_best = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
current, best, id
|
||||
);
|
||||
}
|
||||
|
||||
public void deactivate(String id) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE habits SET is_active = 0, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
public void delete(String userId, String id) {
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM habit_logs WHERE habit_id = (SELECT id FROM habits WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?))",
|
||||
id, userId
|
||||
);
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM habits WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||
id, userId
|
||||
);
|
||||
}
|
||||
|
||||
// --- Habit Logs ---
|
||||
|
||||
public void insertLog(String habitId, String note) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO habit_logs (id, habit_id, log_date, checked_in, note, created_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), TRUNC(SYSDATE), 1, ?, SYSTIMESTAMP)",
|
||||
habitId, note
|
||||
);
|
||||
}
|
||||
|
||||
public boolean hasCheckedInToday(String habitId) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM habit_logs WHERE habit_id = HEXTORAW(?) AND log_date = TRUNC(SYSDATE) AND checked_in = 1",
|
||||
Integer.class, habitId
|
||||
);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getLogs(String habitId, String from, String to) {
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT RAWTOHEX(id) AS id, TO_CHAR(log_date, 'YYYY-MM-DD') AS log_date, checked_in, note, created_at " +
|
||||
"FROM habit_logs WHERE habit_id = HEXTORAW(?)"
|
||||
);
|
||||
java.util.ArrayList<Object> params = new java.util.ArrayList<>();
|
||||
params.add(habitId);
|
||||
|
||||
if (from != null && !from.isEmpty()) {
|
||||
sql.append(" AND log_date >= TO_DATE(?, 'YYYY-MM-DD')");
|
||||
params.add(from);
|
||||
}
|
||||
if (to != null && !to.isEmpty()) {
|
||||
sql.append(" AND log_date <= TO_DATE(?, 'YYYY-MM-DD')");
|
||||
params.add(to);
|
||||
}
|
||||
sql.append(" ORDER BY log_date DESC");
|
||||
|
||||
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* 연속 체크인 일수 계산 (오늘부터 역순)
|
||||
*/
|
||||
public int calculateCurrentStreak(String habitId) {
|
||||
List<Map<String, Object>> logs = jdbcTemplate.queryForList(
|
||||
"SELECT TO_CHAR(log_date, 'YYYY-MM-DD') AS log_date FROM habit_logs " +
|
||||
"WHERE habit_id = HEXTORAW(?) AND checked_in = 1 ORDER BY log_date DESC",
|
||||
habitId
|
||||
);
|
||||
if (logs.isEmpty()) return 0;
|
||||
|
||||
int streak = 0;
|
||||
java.time.LocalDate expected = java.time.LocalDate.now();
|
||||
|
||||
for (Map<String, Object> log : logs) {
|
||||
java.time.LocalDate logDate = java.time.LocalDate.parse((String) log.get("LOG_DATE"));
|
||||
if (logDate.equals(expected)) {
|
||||
streak++;
|
||||
expected = expected.minusDays(1);
|
||||
} else if (logDate.equals(expected.minusDays(1)) && streak == 0) {
|
||||
// 어제까지 연속이면 (오늘 아직 안 한 경우)
|
||||
expected = logDate;
|
||||
streak++;
|
||||
expected = expected.minusDays(1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.sundol.repository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class KnowledgeChunkRepository {
|
||||
|
||||
@@ -12,5 +15,34 @@ public class KnowledgeChunkRepository {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
// TODO: CRUD for knowledge_chunks, VECTOR_DISTANCE search
|
||||
public void insertChunk(String knowledgeItemId, int chunkIndex, String content, int tokenCount) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO knowledge_chunks (id, knowledge_item_id, chunk_index, content, token_count, created_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, SYSTIMESTAMP)",
|
||||
knowledgeItemId, chunkIndex, content, tokenCount
|
||||
);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findByKnowledgeItemId(String knowledgeItemId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, chunk_index, content, token_count, created_at " +
|
||||
"FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?) ORDER BY chunk_index",
|
||||
knowledgeItemId
|
||||
);
|
||||
}
|
||||
|
||||
public int countByKnowledgeItemId(String knowledgeItemId) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?)",
|
||||
Integer.class, knowledgeItemId
|
||||
);
|
||||
return count != null ? count : 0;
|
||||
}
|
||||
|
||||
public void deleteByKnowledgeItemId(String knowledgeItemId) {
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?)",
|
||||
knowledgeItemId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.sundol.repository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class KnowledgeRepository {
|
||||
|
||||
@@ -12,5 +15,80 @@ public class KnowledgeRepository {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
// TODO: CRUD for knowledge_items table
|
||||
public String insert(String userId, String type, String title, String sourceUrl, String rawText) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO knowledge_items (id, user_id, type, title, source_url, raw_text, status, created_at, updated_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, 'PENDING', SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||
userId, type, title, sourceUrl, rawText
|
||||
);
|
||||
// Get the ID of the just-inserted row
|
||||
var result = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id FROM knowledge_items WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||
userId
|
||||
);
|
||||
return (String) result.get(0).get("ID");
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> list(String userId, String type, String status, String search) {
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT RAWTOHEX(id) AS id, type, title, source_url, status, created_at, updated_at FROM knowledge_items WHERE user_id = HEXTORAW(?)"
|
||||
);
|
||||
java.util.List<Object> params = new java.util.ArrayList<>();
|
||||
params.add(userId);
|
||||
|
||||
if (type != null && !type.isEmpty()) {
|
||||
sql.append(" AND type = ?");
|
||||
params.add(type);
|
||||
}
|
||||
if (status != null && !status.isEmpty()) {
|
||||
sql.append(" AND status = ?");
|
||||
params.add(status);
|
||||
}
|
||||
if (search != null && !search.isEmpty()) {
|
||||
sql.append(" AND UPPER(title) LIKE UPPER(?)");
|
||||
params.add("%" + search + "%");
|
||||
}
|
||||
sql.append(" ORDER BY created_at DESC");
|
||||
|
||||
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
}
|
||||
|
||||
public Map<String, Object> findById(String userId, String id) {
|
||||
var results = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(user_id) AS user_id, type, title, source_url, raw_text, status, created_at, updated_at " +
|
||||
"FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||
id, userId
|
||||
);
|
||||
return results.isEmpty() ? null : results.get(0);
|
||||
}
|
||||
|
||||
public Map<String, Object> findByIdInternal(String id) {
|
||||
var results = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(user_id) AS user_id, type, title, source_url, raw_text, status, created_at, updated_at " +
|
||||
"FROM knowledge_items WHERE RAWTOHEX(id) = ?",
|
||||
id
|
||||
);
|
||||
return results.isEmpty() ? null : results.get(0);
|
||||
}
|
||||
|
||||
public void updateStatus(String id, String status) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE knowledge_items SET status = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
status, id
|
||||
);
|
||||
}
|
||||
|
||||
public void updateTitle(String id, String title) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE knowledge_items SET title = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
title, id
|
||||
);
|
||||
}
|
||||
|
||||
public void delete(String userId, String id) {
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||
id, userId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.sundol.repository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class StudyCardRepository {
|
||||
|
||||
@@ -12,5 +15,80 @@ public class StudyCardRepository {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
// TODO: CRUD for study_cards, SM-2 queries
|
||||
public String insert(String userId, String knowledgeItemId, String front, String back) {
|
||||
if (knowledgeItemId != null) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO study_cards (id, user_id, knowledge_item_id, front, back, ease_factor, interval_days, repetitions, next_review_at, created_at, updated_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), HEXTORAW(?), ?, ?, 2.50, 0, 0, SYSTIMESTAMP, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||
userId, knowledgeItemId, front, back
|
||||
);
|
||||
} else {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO study_cards (id, user_id, knowledge_item_id, front, back, ease_factor, interval_days, repetitions, next_review_at, created_at, updated_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), NULL, ?, ?, 2.50, 0, 0, SYSTIMESTAMP, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||
userId, front, back
|
||||
);
|
||||
}
|
||||
var result = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id FROM study_cards WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||
userId
|
||||
);
|
||||
return (String) result.get(0).get("ID");
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getDueCards(String userId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(sc.id) AS id, RAWTOHEX(sc.knowledge_item_id) AS knowledge_item_id, " +
|
||||
"sc.front, sc.back, sc.ease_factor, sc.interval_days, sc.repetitions, sc.next_review_at, " +
|
||||
"ki.title AS knowledge_title " +
|
||||
"FROM study_cards sc " +
|
||||
"LEFT JOIN knowledge_items ki ON ki.id = sc.knowledge_item_id " +
|
||||
"WHERE sc.user_id = HEXTORAW(?) AND sc.next_review_at <= SYSTIMESTAMP " +
|
||||
"ORDER BY sc.next_review_at ASC",
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getByKnowledgeItem(String userId, String knowledgeItemId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, front, back, ease_factor, interval_days, repetitions, next_review_at, created_at " +
|
||||
"FROM study_cards WHERE user_id = HEXTORAW(?) AND knowledge_item_id = HEXTORAW(?) ORDER BY created_at ASC",
|
||||
userId, knowledgeItemId
|
||||
);
|
||||
}
|
||||
|
||||
public Map<String, Object> findById(String userId, String id) {
|
||||
var results = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(knowledge_item_id) AS knowledge_item_id, " +
|
||||
"front, back, ease_factor, interval_days, repetitions, next_review_at, created_at, updated_at " +
|
||||
"FROM study_cards WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||
id, userId
|
||||
);
|
||||
return results.isEmpty() ? null : results.get(0);
|
||||
}
|
||||
|
||||
public void updateSm2(String id, double easeFactor, int intervalDays, int repetitions, int nextReviewDaysFromNow) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE study_cards SET ease_factor = ?, interval_days = ?, repetitions = ?, " +
|
||||
"next_review_at = SYSTIMESTAMP + INTERVAL '1' DAY * ?, updated_at = SYSTIMESTAMP " +
|
||||
"WHERE RAWTOHEX(id) = ?",
|
||||
easeFactor, intervalDays, repetitions, nextReviewDaysFromNow, id
|
||||
);
|
||||
}
|
||||
|
||||
public int countDue(String userId) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM study_cards WHERE user_id = HEXTORAW(?) AND next_review_at <= SYSTIMESTAMP",
|
||||
Integer.class, userId
|
||||
);
|
||||
return count != null ? count : 0;
|
||||
}
|
||||
|
||||
public int countByKnowledgeItem(String knowledgeItemId) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM study_cards WHERE knowledge_item_id = HEXTORAW(?)",
|
||||
Integer.class, knowledgeItemId
|
||||
);
|
||||
return count != null ? count : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.sundol.repository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class TagRepository {
|
||||
|
||||
@@ -12,5 +15,83 @@ public class TagRepository {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
// TODO: CRUD for tags, knowledge_item_tags
|
||||
public String insert(String userId, String name, String color) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO tags (id, user_id, name, color, created_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, SYSTIMESTAMP)",
|
||||
userId, name, color
|
||||
);
|
||||
var result = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id FROM tags WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||
userId
|
||||
);
|
||||
return (String) result.get(0).get("ID");
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> list(String userId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(t.id) AS id, t.name, t.color, t.created_at, " +
|
||||
"(SELECT COUNT(*) FROM knowledge_item_tags kit WHERE kit.tag_id = t.id) AS item_count " +
|
||||
"FROM tags t WHERE t.user_id = HEXTORAW(?) ORDER BY t.name",
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
public Map<String, Object> findById(String userId, String id) {
|
||||
var results = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, name, color, created_at " +
|
||||
"FROM tags WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||
id, userId
|
||||
);
|
||||
return results.isEmpty() ? null : results.get(0);
|
||||
}
|
||||
|
||||
public void updateName(String id, String name) {
|
||||
jdbcTemplate.update("UPDATE tags SET name = ? WHERE RAWTOHEX(id) = ?", name, id);
|
||||
}
|
||||
|
||||
public void updateColor(String id, String color) {
|
||||
jdbcTemplate.update("UPDATE tags SET color = ? WHERE RAWTOHEX(id) = ?", color, id);
|
||||
}
|
||||
|
||||
public void delete(String userId, String id) {
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM knowledge_item_tags WHERE tag_id = (SELECT id FROM tags WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?))",
|
||||
id, userId
|
||||
);
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM tags WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||
id, userId
|
||||
);
|
||||
}
|
||||
|
||||
// --- Knowledge Item ↔ Tag 매핑 ---
|
||||
|
||||
public void tagItem(String knowledgeItemId, String tagId) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM knowledge_item_tags WHERE knowledge_item_id = HEXTORAW(?) AND tag_id = HEXTORAW(?)",
|
||||
Integer.class, knowledgeItemId, tagId
|
||||
);
|
||||
if (count != null && count > 0) return;
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO knowledge_item_tags (knowledge_item_id, tag_id) VALUES (HEXTORAW(?), HEXTORAW(?))",
|
||||
knowledgeItemId, tagId
|
||||
);
|
||||
}
|
||||
|
||||
public void untagItem(String knowledgeItemId, String tagId) {
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM knowledge_item_tags WHERE knowledge_item_id = HEXTORAW(?) AND tag_id = HEXTORAW(?)",
|
||||
knowledgeItemId, tagId
|
||||
);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getTagsForItem(String knowledgeItemId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(t.id) AS id, t.name, t.color " +
|
||||
"FROM tags t JOIN knowledge_item_tags kit ON kit.tag_id = t.id " +
|
||||
"WHERE kit.knowledge_item_id = HEXTORAW(?) ORDER BY t.name",
|
||||
knowledgeItemId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ package com.sundol.repository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class TodoRepository {
|
||||
|
||||
@@ -12,5 +16,134 @@ public class TodoRepository {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
// TODO: CRUD for todos table
|
||||
public String insert(String userId, String title, String description, String priority, String dueDate, String parentId) {
|
||||
if (parentId != null) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO todos (id, user_id, parent_id, title, description, status, priority, due_date, created_at, updated_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), HEXTORAW(?), ?, ?, 'PENDING', ?, " +
|
||||
"CASE WHEN ? IS NOT NULL THEN TO_DATE(?, 'YYYY-MM-DD') ELSE NULL END, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||
userId, parentId, title, description, priority, dueDate, dueDate
|
||||
);
|
||||
} else {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO todos (id, user_id, parent_id, title, description, status, priority, due_date, created_at, updated_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), NULL, ?, ?, 'PENDING', ?, " +
|
||||
"CASE WHEN ? IS NOT NULL THEN TO_DATE(?, 'YYYY-MM-DD') ELSE NULL END, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||
userId, title, description, priority, dueDate, dueDate
|
||||
);
|
||||
}
|
||||
|
||||
var result = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id FROM todos WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||
userId
|
||||
);
|
||||
return (String) result.get(0).get("ID");
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> list(String userId, String status, String priority, String dueDate) {
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(parent_id) AS parent_id, title, description, status, priority, " +
|
||||
"TO_CHAR(due_date, 'YYYY-MM-DD') AS due_date, created_at, updated_at " +
|
||||
"FROM todos WHERE user_id = HEXTORAW(?) AND parent_id IS NULL"
|
||||
);
|
||||
List<Object> params = new ArrayList<>();
|
||||
params.add(userId);
|
||||
|
||||
if (status != null && !status.isEmpty()) {
|
||||
sql.append(" AND status = ?");
|
||||
params.add(status);
|
||||
}
|
||||
if (priority != null && !priority.isEmpty()) {
|
||||
sql.append(" AND priority = ?");
|
||||
params.add(priority);
|
||||
}
|
||||
if (dueDate != null && !dueDate.isEmpty()) {
|
||||
sql.append(" AND due_date <= TO_DATE(?, 'YYYY-MM-DD')");
|
||||
params.add(dueDate);
|
||||
}
|
||||
sql.append(" ORDER BY CASE priority WHEN 'HIGH' THEN 1 WHEN 'MEDIUM' THEN 2 WHEN 'LOW' THEN 3 END, created_at DESC");
|
||||
|
||||
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
}
|
||||
|
||||
public Map<String, Object> findById(String userId, String id) {
|
||||
var results = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(parent_id) AS parent_id, title, description, status, priority, " +
|
||||
"TO_CHAR(due_date, 'YYYY-MM-DD') AS due_date, created_at, updated_at " +
|
||||
"FROM todos WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||
id, userId
|
||||
);
|
||||
return results.isEmpty() ? null : results.get(0);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findSubtasks(String userId, String parentId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(parent_id) AS parent_id, title, description, status, priority, " +
|
||||
"TO_CHAR(due_date, 'YYYY-MM-DD') AS due_date, created_at, updated_at " +
|
||||
"FROM todos WHERE parent_id = HEXTORAW(?) AND user_id = HEXTORAW(?) ORDER BY created_at ASC",
|
||||
parentId, userId
|
||||
);
|
||||
}
|
||||
|
||||
public void updateStatus(String id, String status) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE todos SET status = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
status, id
|
||||
);
|
||||
}
|
||||
|
||||
public void updateTitle(String id, String title) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE todos SET title = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
title, id
|
||||
);
|
||||
}
|
||||
|
||||
public void updatePriority(String id, String priority) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE todos SET priority = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
priority, id
|
||||
);
|
||||
}
|
||||
|
||||
public void updateDueDate(String id, String dueDate) {
|
||||
if (dueDate != null && !dueDate.isEmpty()) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE todos SET due_date = TO_DATE(?, 'YYYY-MM-DD'), updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
dueDate, id
|
||||
);
|
||||
} else {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE todos SET due_date = NULL, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public void delete(String userId, String id) {
|
||||
// 서브태스크 먼저 삭제
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM todos WHERE parent_id = HEXTORAW(?) AND user_id = HEXTORAW(?)",
|
||||
id, userId
|
||||
);
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM todos WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||
id, userId
|
||||
);
|
||||
}
|
||||
|
||||
public int countByUser(String userId, String status) {
|
||||
if (status != null) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM todos WHERE user_id = HEXTORAW(?) AND status = ?",
|
||||
Integer.class, userId, status
|
||||
);
|
||||
return count != null ? count : 0;
|
||||
}
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM todos WHERE user_id = HEXTORAW(?)",
|
||||
Integer.class, userId
|
||||
);
|
||||
return count != null ? count : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.sundol.repository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class UserRepository {
|
||||
|
||||
@@ -12,5 +14,43 @@ public class UserRepository {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
// TODO: findByGoogleSub, upsert, updateRefreshToken
|
||||
public Map<String, Object> findByGoogleSub(String googleSub) {
|
||||
var results = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub FROM users WHERE google_sub = ?",
|
||||
googleSub
|
||||
);
|
||||
return results.isEmpty() ? null : results.get(0);
|
||||
}
|
||||
|
||||
public Map<String, Object> upsert(String email, String displayName, String avatarUrl, String googleSub) {
|
||||
var existing = findByGoogleSub(googleSub);
|
||||
if (existing != null) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE users SET display_name = ?, avatar_url = ?, updated_at = SYSTIMESTAMP WHERE google_sub = ?",
|
||||
displayName, avatarUrl, googleSub
|
||||
);
|
||||
return findByGoogleSub(googleSub);
|
||||
} else {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (id, email, display_name, avatar_url, google_sub, created_at, updated_at) VALUES (SYS_GUID(), ?, ?, ?, ?, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||
email, displayName, avatarUrl, googleSub
|
||||
);
|
||||
return findByGoogleSub(googleSub);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateRefreshToken(String userId, String hashedRefreshToken) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE users SET refresh_token = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
hashedRefreshToken, userId
|
||||
);
|
||||
}
|
||||
|
||||
public Map<String, Object> findById(String userId) {
|
||||
var results = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub, refresh_token FROM users WHERE RAWTOHEX(id) = ?",
|
||||
userId
|
||||
);
|
||||
return results.isEmpty() ? null : results.get(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,147 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.sundol.dto.LoginResponse;
|
||||
import com.sundol.exception.AppException;
|
||||
import com.sundol.repository.UserRepository;
|
||||
import com.sundol.security.JwtProvider;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final JwtProvider jwtProvider;
|
||||
private final WebClient webClient;
|
||||
|
||||
@Value("${google.client-id}")
|
||||
private String googleClientId;
|
||||
|
||||
@Value("${google.client-secret}")
|
||||
private String googleClientSecret;
|
||||
|
||||
@Value("${google.redirect-uri}")
|
||||
private String googleRedirectUri;
|
||||
|
||||
public AuthService(UserRepository userRepository, JwtProvider jwtProvider) {
|
||||
this.userRepository = userRepository;
|
||||
this.jwtProvider = jwtProvider;
|
||||
this.webClient = WebClient.builder().build();
|
||||
}
|
||||
|
||||
public Mono<LoginResponse> googleLogin(String idToken) {
|
||||
// TODO: Verify Google ID token, upsert user, issue JWT pair
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
public Mono<LoginResponse> googleLogin(String code) {
|
||||
// Exchange auth code for tokens via Google token endpoint
|
||||
return webClient.post()
|
||||
.uri("https://oauth2.googleapis.com/token")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.bodyValue("code=" + code
|
||||
+ "&client_id=" + googleClientId
|
||||
+ "&client_secret=" + googleClientSecret
|
||||
+ "&redirect_uri=" + googleRedirectUri
|
||||
+ "&grant_type=authorization_code")
|
||||
.retrieve()
|
||||
.bodyToMono(Map.class)
|
||||
.flatMap(tokenResponse -> {
|
||||
String accessToken = (String) tokenResponse.get("access_token");
|
||||
if (accessToken == null) {
|
||||
return Mono.error(new AppException(HttpStatus.UNAUTHORIZED, "Failed to exchange auth code"));
|
||||
}
|
||||
// Get user info from Google
|
||||
return webClient.get()
|
||||
.uri("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||
.header("Authorization", "Bearer " + accessToken)
|
||||
.retrieve()
|
||||
.bodyToMono(Map.class);
|
||||
})
|
||||
.flatMap(userInfo -> Mono.fromCallable(() -> {
|
||||
String googleSub = (String) userInfo.get("id");
|
||||
String email = (String) userInfo.get("email");
|
||||
String name = (String) userInfo.get("name");
|
||||
String picture = (String) userInfo.get("picture");
|
||||
|
||||
if (googleSub == null || email == null) {
|
||||
throw new AppException(HttpStatus.UNAUTHORIZED, "Invalid Google user info");
|
||||
}
|
||||
|
||||
// Upsert user
|
||||
Map<String, Object> user = userRepository.upsert(email, name, picture, googleSub);
|
||||
String userId = (String) user.get("ID");
|
||||
|
||||
// Create JWT pair
|
||||
String jwt = jwtProvider.createAccessToken(userId, email);
|
||||
String refreshToken = jwtProvider.createRefreshToken(userId, email);
|
||||
|
||||
// Store hashed refresh token
|
||||
userRepository.updateRefreshToken(userId, sha256(refreshToken));
|
||||
|
||||
log.info("User logged in: {} ({})", email, userId);
|
||||
return new LoginResponse(jwt, refreshToken);
|
||||
}).subscribeOn(Schedulers.boundedElastic()));
|
||||
}
|
||||
|
||||
public Mono<LoginResponse> refresh(String refreshToken) {
|
||||
// TODO: Validate refresh token, issue new token pair
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
if (!jwtProvider.validateToken(refreshToken)) {
|
||||
throw new AppException(HttpStatus.UNAUTHORIZED, "Invalid refresh token");
|
||||
}
|
||||
|
||||
var claims = jwtProvider.parseToken(refreshToken);
|
||||
String type = claims.get("type", String.class);
|
||||
if (!"REFRESH".equals(type)) {
|
||||
throw new AppException(HttpStatus.UNAUTHORIZED, "Not a refresh token");
|
||||
}
|
||||
|
||||
String userId = claims.getSubject();
|
||||
Map<String, Object> user = userRepository.findById(userId);
|
||||
if (user == null) {
|
||||
throw new AppException(HttpStatus.UNAUTHORIZED, "User not found");
|
||||
}
|
||||
|
||||
// Verify stored hash matches
|
||||
String storedHash = (String) user.get("REFRESH_TOKEN");
|
||||
if (storedHash == null || !storedHash.equals(sha256(refreshToken))) {
|
||||
throw new AppException(HttpStatus.UNAUTHORIZED, "Refresh token revoked");
|
||||
}
|
||||
|
||||
String email = (String) user.get("EMAIL");
|
||||
|
||||
// Issue new pair (rotation)
|
||||
String newAccessToken = jwtProvider.createAccessToken(userId, email);
|
||||
String newRefreshToken = jwtProvider.createRefreshToken(userId, email);
|
||||
userRepository.updateRefreshToken(userId, sha256(newRefreshToken));
|
||||
|
||||
return new LoginResponse(newAccessToken, newRefreshToken);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Void> logout(String userId) {
|
||||
// TODO: Invalidate refresh token
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromRunnable(() -> {
|
||||
userRepository.updateRefreshToken(userId, null);
|
||||
log.info("User logged out: {}", userId);
|
||||
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||
}
|
||||
|
||||
private String sha256(String input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(hash);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,186 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sundol.exception.AppException;
|
||||
import com.sundol.repository.ChatRepository;
|
||||
import com.sundol.repository.ChunkEmbeddingRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ChatService {
|
||||
|
||||
private final ChatRepository chatRepository;
|
||||
private static final Logger log = LoggerFactory.getLogger(ChatService.class);
|
||||
private static final int RAG_TOP_K = 5;
|
||||
|
||||
public ChatService(ChatRepository chatRepository) {
|
||||
private final ChatRepository chatRepository;
|
||||
private final ChunkEmbeddingRepository embeddingRepository;
|
||||
private final OciGenAiService genAiService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ChatService(
|
||||
ChatRepository chatRepository,
|
||||
ChunkEmbeddingRepository embeddingRepository,
|
||||
OciGenAiService genAiService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.chatRepository = chatRepository;
|
||||
this.embeddingRepository = embeddingRepository;
|
||||
this.genAiService = genAiService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public Mono<List<Map<String, Object>>> listSessions(String userId) {
|
||||
// TODO: List chat sessions for user
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> chatRepository.listSessions(userId))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Map<String, Object>> createSession(String userId) {
|
||||
// TODO: Create new chat session
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
String id = chatRepository.createSession(userId, "New Chat");
|
||||
return chatRepository.findSession(userId, id);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<List<Map<String, Object>>> getMessages(String userId, String sessionId) {
|
||||
// TODO: Get messages for chat session
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> session = chatRepository.findSession(userId, sessionId);
|
||||
if (session == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Chat session not found");
|
||||
}
|
||||
return chatRepository.getMessages(sessionId);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Map<String, Object>> sendMessage(String userId, String sessionId, String content) {
|
||||
// TODO: RAG pipeline - embed query, search, build prompt, call OCI GenAI
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> session = chatRepository.findSession(userId, sessionId);
|
||||
if (session == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Chat session not found");
|
||||
}
|
||||
|
||||
// 1. 사용자 메시지 저장
|
||||
chatRepository.insertMessage(sessionId, "user", content, null, null);
|
||||
|
||||
// 2. RAG: 쿼리 임베딩 → 유사 청크 검색
|
||||
List<Map<String, Object>> relevantChunks = List.of();
|
||||
String contextBlock = "";
|
||||
if (genAiService.isConfigured()) {
|
||||
try {
|
||||
List<float[]> embeddings = genAiService.embedTexts(List.of(content), "SEARCH_QUERY");
|
||||
relevantChunks = embeddingRepository.searchSimilar(userId, embeddings.get(0), RAG_TOP_K);
|
||||
|
||||
if (!relevantChunks.isEmpty()) {
|
||||
contextBlock = relevantChunks.stream()
|
||||
.map(chunk -> {
|
||||
String title = (String) chunk.get("TITLE");
|
||||
String chunkContent = (String) chunk.get("CONTENT");
|
||||
String source = (String) chunk.get("SOURCE_URL");
|
||||
String header = title != null ? "[" + title + "]" : "[Untitled]";
|
||||
if (source != null) header += " (" + source + ")";
|
||||
return header + "\n" + chunkContent;
|
||||
})
|
||||
.collect(Collectors.joining("\n\n---\n\n"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("RAG search failed, proceeding without context", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 대화 히스토리 조회 (최근 20개)
|
||||
List<Map<String, Object>> history = chatRepository.getMessages(sessionId);
|
||||
int historyStart = Math.max(0, history.size() - 20);
|
||||
List<Map<String, Object>> recentHistory = history.subList(historyStart, history.size());
|
||||
|
||||
// 4. LLM 프롬프트 구성
|
||||
String systemMsg = buildSystemPrompt(contextBlock);
|
||||
String userMsg = buildUserPrompt(recentHistory, content);
|
||||
|
||||
// 5. LLM 호출
|
||||
String assistantResponse;
|
||||
try {
|
||||
assistantResponse = genAiService.chat(systemMsg, userMsg, null);
|
||||
} catch (Exception e) {
|
||||
log.error("LLM chat failed", e);
|
||||
assistantResponse = "죄송합니다, 응답을 생성하는 중 오류가 발생했습니다.";
|
||||
}
|
||||
|
||||
// 6. 응답 저장
|
||||
String sourceChunksJson = null;
|
||||
if (!relevantChunks.isEmpty()) {
|
||||
try {
|
||||
List<Map<String, Object>> sources = relevantChunks.stream()
|
||||
.map(c -> Map.of(
|
||||
"knowledgeItemId", (Object) c.get("KNOWLEDGE_ITEM_ID"),
|
||||
"title", c.get("TITLE") != null ? c.get("TITLE") : "Untitled",
|
||||
"chunkIndex", c.get("CHUNK_INDEX"),
|
||||
"distance", c.get("DISTANCE")
|
||||
))
|
||||
.toList();
|
||||
sourceChunksJson = objectMapper.writeValueAsString(sources);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to serialize source chunks", e);
|
||||
}
|
||||
}
|
||||
chatRepository.insertMessage(sessionId, "assistant", assistantResponse, sourceChunksJson, null);
|
||||
|
||||
// 7. 첫 메시지면 세션 제목 업데이트
|
||||
if (history.size() <= 1) {
|
||||
String sessionTitle = content.length() > 50
|
||||
? content.substring(0, 50) + "..." : content;
|
||||
chatRepository.updateSessionTitle(sessionId, sessionTitle);
|
||||
}
|
||||
chatRepository.touchSession(sessionId);
|
||||
|
||||
return Map.of(
|
||||
"role", (Object) "assistant",
|
||||
"content", assistantResponse,
|
||||
"sourceChunks", sourceChunksJson != null ? sourceChunksJson : "[]"
|
||||
);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
private String buildSystemPrompt(String contextBlock) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("You are SUNDOL, a helpful personal knowledge assistant. ");
|
||||
sb.append("Answer questions based on the user's knowledge base. ");
|
||||
sb.append("If the context contains relevant information, use it to answer accurately. ");
|
||||
sb.append("If the context doesn't contain relevant information, say so honestly. ");
|
||||
sb.append("Respond in the same language the user uses.");
|
||||
|
||||
if (!contextBlock.isBlank()) {
|
||||
sb.append("\n\n--- Knowledge Context ---\n\n");
|
||||
sb.append(contextBlock);
|
||||
sb.append("\n\n--- End Context ---");
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String buildUserPrompt(List<Map<String, Object>> history, String currentMessage) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
// 최근 대화 히스토리를 포함하여 맥락 유지 (현재 메시지 제외)
|
||||
for (int i = 0; i < history.size() - 1; i++) {
|
||||
Map<String, Object> msg = history.get(i);
|
||||
String role = (String) msg.get("ROLE");
|
||||
Object contentObj = msg.get("CONTENT");
|
||||
String content = contentObj != null ? contentObj.toString() : "";
|
||||
sb.append(role.equals("user") ? "User: " : "Assistant: ");
|
||||
sb.append(content).append("\n\n");
|
||||
}
|
||||
sb.append(currentMessage);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public Mono<Void> deleteSession(String userId, String sessionId) {
|
||||
// TODO: Delete chat session and messages
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromRunnable(() -> chatRepository.deleteSession(userId, sessionId))
|
||||
.subscribeOn(Schedulers.boundedElastic()).then();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class ChunkingService {
|
||||
|
||||
private static final int CHUNK_SIZE_TOKENS = 500;
|
||||
private static final int CHUNK_OVERLAP_TOKENS = 50;
|
||||
|
||||
public List<String> chunk(String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
String[] words = text.split("\\s+");
|
||||
int chunkWords = (int) (CHUNK_SIZE_TOKENS * 0.75);
|
||||
int overlapWords = (int) (CHUNK_OVERLAP_TOKENS * 0.75);
|
||||
|
||||
List<String> chunks = new ArrayList<>();
|
||||
int i = 0;
|
||||
while (i < words.length) {
|
||||
int end = Math.min(i + chunkWords, words.length);
|
||||
chunks.add(String.join(" ", java.util.Arrays.copyOfRange(words, i, end)));
|
||||
i += chunkWords - overlapWords;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
public int estimateTokenCount(String text) {
|
||||
if (text == null || text.isBlank()) return 0;
|
||||
return (int) (text.split("\\s+").length / 0.75);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.sundol.dto.HabitRequest;
|
||||
import com.sundol.exception.AppException;
|
||||
import com.sundol.repository.HabitRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -18,32 +21,85 @@ public class HabitService {
|
||||
}
|
||||
|
||||
public Mono<List<Map<String, Object>>> list(String userId) {
|
||||
// TODO: List habits for user
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
List<Map<String, Object>> habits = habitRepository.list(userId);
|
||||
for (Map<String, Object> habit : habits) {
|
||||
String habitId = (String) habit.get("ID");
|
||||
habit.put("CHECKED_TODAY", habitRepository.hasCheckedInToday(habitId));
|
||||
}
|
||||
return habits;
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Map<String, Object>> create(String userId, HabitRequest request) {
|
||||
// TODO: Create habit
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
String habitType = request.habitType() != null ? request.habitType() : "DAILY";
|
||||
String color = request.color() != null ? request.color() : "#6366f1";
|
||||
String id = habitRepository.insert(
|
||||
userId, request.name(), request.description(),
|
||||
habitType, request.targetDays(), color
|
||||
);
|
||||
return habitRepository.findById(userId, id);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
|
||||
// TODO: Update habit
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> habit = habitRepository.findById(userId, id);
|
||||
if (habit == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
|
||||
}
|
||||
if (updates.containsKey("name")) {
|
||||
habitRepository.updateName(id, (String) updates.get("name"));
|
||||
}
|
||||
return habitRepository.findById(userId, id);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Void> delete(String userId, String id) {
|
||||
// TODO: Delete habit and logs
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromRunnable(() -> {
|
||||
Map<String, Object> habit = habitRepository.findById(userId, id);
|
||||
if (habit == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
|
||||
}
|
||||
habitRepository.delete(userId, id);
|
||||
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||
}
|
||||
|
||||
public Mono<Map<String, Object>> checkin(String userId, String id, String note) {
|
||||
// TODO: Check in for today
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> habit = habitRepository.findById(userId, id);
|
||||
if (habit == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
|
||||
}
|
||||
if (habitRepository.hasCheckedInToday(id)) {
|
||||
throw new AppException(HttpStatus.CONFLICT, "Already checked in today");
|
||||
}
|
||||
|
||||
habitRepository.insertLog(id, note);
|
||||
|
||||
// 스트릭 갱신
|
||||
int currentStreak = habitRepository.calculateCurrentStreak(id);
|
||||
Number bestStreakNum = (Number) habit.get("STREAK_BEST");
|
||||
int bestStreak = bestStreakNum != null ? bestStreakNum.intValue() : 0;
|
||||
if (currentStreak > bestStreak) {
|
||||
bestStreak = currentStreak;
|
||||
}
|
||||
habitRepository.updateStreak(id, currentStreak, bestStreak);
|
||||
|
||||
Map<String, Object> updated = habitRepository.findById(userId, id);
|
||||
updated.put("CHECKED_TODAY", true);
|
||||
return updated;
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<List<Map<String, Object>>> getLogs(String userId, String id, String from, String to) {
|
||||
// TODO: Get habit logs
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> habit = habitRepository.findById(userId, id);
|
||||
if (habit == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
|
||||
}
|
||||
return habitRepository.getLogs(id, from, to);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sundol.repository.CategoryRepository;
|
||||
import com.sundol.repository.ChunkEmbeddingRepository;
|
||||
import com.sundol.repository.KnowledgeChunkRepository;
|
||||
import com.sundol.repository.KnowledgeRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class IngestPipelineService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(IngestPipelineService.class);
|
||||
|
||||
private final KnowledgeRepository knowledgeRepository;
|
||||
private final KnowledgeChunkRepository chunkRepository;
|
||||
private final ChunkEmbeddingRepository embeddingRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final ChunkingService chunkingService;
|
||||
private final WebCrawlerService webCrawlerService;
|
||||
private final OciGenAiService genAiService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public IngestPipelineService(
|
||||
KnowledgeRepository knowledgeRepository,
|
||||
KnowledgeChunkRepository chunkRepository,
|
||||
ChunkEmbeddingRepository embeddingRepository,
|
||||
CategoryRepository categoryRepository,
|
||||
ChunkingService chunkingService,
|
||||
WebCrawlerService webCrawlerService,
|
||||
OciGenAiService genAiService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.knowledgeRepository = knowledgeRepository;
|
||||
this.chunkRepository = chunkRepository;
|
||||
this.embeddingRepository = embeddingRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.chunkingService = chunkingService;
|
||||
this.webCrawlerService = webCrawlerService;
|
||||
this.genAiService = genAiService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
private static final int TITLE_MAX_LENGTH = 80;
|
||||
private static final int TEXT_PREVIEW_LENGTH = 3000;
|
||||
|
||||
/**
|
||||
* LLM으로 내용 기반 제목 생성. 실패 시 텍스트 앞부분으로 폴백.
|
||||
*/
|
||||
private String generateTitle(String text, String modelId) {
|
||||
if (genAiService.isConfigured()) {
|
||||
try {
|
||||
String preview = text.length() > TEXT_PREVIEW_LENGTH
|
||||
? text.substring(0, TEXT_PREVIEW_LENGTH) : text;
|
||||
|
||||
String systemMsg = "You are a helpful assistant that generates concise titles. " +
|
||||
"Respond with ONLY the title, nothing else. " +
|
||||
"The title should be in the same language as the content. " +
|
||||
"Keep it under 60 characters.";
|
||||
String userMsg = "Generate a concise, descriptive title for the following content:\n\n" + preview;
|
||||
|
||||
String title = genAiService.chat(systemMsg, userMsg, modelId).strip();
|
||||
// 따옴표 제거
|
||||
title = title.replaceAll("^\"|\"$", "").replaceAll("^'|'$", "");
|
||||
if (!title.isBlank() && title.length() <= TITLE_MAX_LENGTH) {
|
||||
log.info("LLM generated title: {}", title);
|
||||
return title;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("LLM title generation failed, falling back to text truncation", e);
|
||||
}
|
||||
}
|
||||
// Fallback: 텍스트 첫 줄
|
||||
String firstLine = text.strip().split("\\r?\\n", 2)[0].strip();
|
||||
if (firstLine.length() <= TITLE_MAX_LENGTH) {
|
||||
return firstLine;
|
||||
}
|
||||
return firstLine.substring(0, TITLE_MAX_LENGTH - 3) + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM으로 2~4 depth 카테고리 추출. 기존 카테고리 목록을 컨텍스트로 전달.
|
||||
*/
|
||||
private List<String> extractCategoryPaths(String userId, String text, String modelId) {
|
||||
if (!genAiService.isConfigured()) {
|
||||
log.info("OCI GenAI not configured, skipping categorization");
|
||||
return List.of();
|
||||
}
|
||||
|
||||
try {
|
||||
// 기존 카테고리 목록
|
||||
List<Map<String, Object>> existing = categoryRepository.findAllByUser(userId);
|
||||
String existingPaths = existing.stream()
|
||||
.map(c -> (String) c.get("FULL_PATH"))
|
||||
.collect(Collectors.joining("\n"));
|
||||
|
||||
String preview = text.length() > TEXT_PREVIEW_LENGTH
|
||||
? text.substring(0, TEXT_PREVIEW_LENGTH) : text;
|
||||
|
||||
String systemMsg = "You are a categorization assistant. " +
|
||||
"Analyze the content and assign 1-3 hierarchical categories. " +
|
||||
"Each category should be 2-4 levels deep, separated by '/'. " +
|
||||
"Examples: '건강/운동/웨이트트레이닝', 'IT/AI/LLM', '요리/한식'. " +
|
||||
"Respond with ONLY a JSON array of category path strings. " +
|
||||
"No explanation, no markdown formatting. Just the JSON array.";
|
||||
|
||||
StringBuilder userMsg = new StringBuilder();
|
||||
if (!existingPaths.isBlank()) {
|
||||
userMsg.append("Existing categories (reuse these when appropriate):\n");
|
||||
userMsg.append(existingPaths);
|
||||
userMsg.append("\n\n");
|
||||
}
|
||||
userMsg.append("Categorize the following content:\n\n");
|
||||
userMsg.append(preview);
|
||||
|
||||
String response = genAiService.chat(systemMsg, userMsg.toString(), modelId).strip();
|
||||
// JSON 배열 파싱 — 마크다운 코드블록 제거
|
||||
response = response.replaceAll("```json\\s*", "").replaceAll("```\\s*", "").strip();
|
||||
|
||||
List<String> paths = objectMapper.readValue(response, new TypeReference<>() {});
|
||||
// 유효성 검증: 슬래시가 있고 빈 값 아닌 것만
|
||||
return paths.stream()
|
||||
.filter(p -> p != null && !p.isBlank() && p.contains("/"))
|
||||
.map(String::strip)
|
||||
.toList();
|
||||
} catch (Exception e) {
|
||||
log.warn("LLM categorization failed", e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 청크들을 임베딩하여 knowledge_chunk_embeddings 테이블에 저장.
|
||||
*/
|
||||
private void embedChunks(String knowledgeItemId, List<String> chunkContents) {
|
||||
if (!genAiService.isConfigured()) {
|
||||
log.info("OCI GenAI not configured, skipping embedding");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// chunk ID 목록 조회
|
||||
List<Map<String, Object>> storedChunks = chunkRepository.findByKnowledgeItemId(knowledgeItemId);
|
||||
if (storedChunks.size() != chunkContents.size()) {
|
||||
log.warn("Chunk count mismatch: stored={}, content={}", storedChunks.size(), chunkContents.size());
|
||||
}
|
||||
|
||||
List<float[]> embeddings = genAiService.embedTexts(chunkContents, "SEARCH_DOCUMENT");
|
||||
|
||||
String embedModelId = "cohere.embed-v4.0";
|
||||
for (int i = 0; i < Math.min(storedChunks.size(), embeddings.size()); i++) {
|
||||
String chunkId = (String) storedChunks.get(i).get("ID");
|
||||
embeddingRepository.upsertEmbedding(chunkId, embedModelId, embeddings.get(i));
|
||||
}
|
||||
|
||||
log.info("Item {} embedded {} chunks", knowledgeItemId, embeddings.size());
|
||||
} catch (Exception e) {
|
||||
log.warn("Embedding failed for item {}, continuing pipeline", knowledgeItemId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void categorize(String knowledgeItemId, String userId, String text, String modelId) {
|
||||
try {
|
||||
List<String> categoryPaths = extractCategoryPaths(userId, text, modelId);
|
||||
for (String path : categoryPaths) {
|
||||
String categoryId = categoryRepository.findOrCreate(userId, path);
|
||||
categoryRepository.linkCategory(knowledgeItemId, categoryId);
|
||||
}
|
||||
log.info("Item {} categorized with {} categories: {}", knowledgeItemId, categoryPaths.size(), categoryPaths);
|
||||
} catch (Exception e) {
|
||||
log.warn("Categorization failed for item {}, continuing pipeline", knowledgeItemId, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Async
|
||||
public void runPipeline(String knowledgeItemId, String modelId) {
|
||||
try {
|
||||
Map<String, Object> item = knowledgeRepository.findByIdInternal(knowledgeItemId);
|
||||
if (item == null) {
|
||||
log.error("Knowledge item not found: {}", knowledgeItemId);
|
||||
return;
|
||||
}
|
||||
|
||||
String type = (String) item.get("TYPE");
|
||||
String sourceUrl = (String) item.get("SOURCE_URL");
|
||||
Object rawTextObj = item.get("RAW_TEXT");
|
||||
String rawText = rawTextObj != null ? rawTextObj.toString() : null;
|
||||
|
||||
// Step 1: Extract text
|
||||
knowledgeRepository.updateStatus(knowledgeItemId, "EXTRACTING");
|
||||
String extractedText;
|
||||
|
||||
switch (type) {
|
||||
case "WEB" -> {
|
||||
extractedText = webCrawlerService.crawl(sourceUrl);
|
||||
// WEB은 페이지 제목을 우선 시도
|
||||
String title = (String) item.get("TITLE");
|
||||
if (title == null || title.isBlank()) {
|
||||
try {
|
||||
String pageTitle = webCrawlerService.extractTitle(sourceUrl);
|
||||
if (pageTitle != null && !pageTitle.isBlank()) {
|
||||
knowledgeRepository.updateTitle(knowledgeItemId, pageTitle);
|
||||
item.put("TITLE", pageTitle);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract title from {}", sourceUrl, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
case "YOUTUBE" -> {
|
||||
if (rawText == null || rawText.isBlank()) {
|
||||
knowledgeRepository.updateStatus(knowledgeItemId, "FAILED");
|
||||
log.error("YouTube item has no raw text: {}", knowledgeItemId);
|
||||
return;
|
||||
}
|
||||
extractedText = rawText;
|
||||
}
|
||||
case "TEXT" -> {
|
||||
if (rawText == null || rawText.isBlank()) {
|
||||
knowledgeRepository.updateStatus(knowledgeItemId, "FAILED");
|
||||
log.error("Text item has no raw text: {}", knowledgeItemId);
|
||||
return;
|
||||
}
|
||||
extractedText = rawText;
|
||||
}
|
||||
default -> {
|
||||
knowledgeRepository.updateStatus(knowledgeItemId, "FAILED");
|
||||
log.error("Unknown type: {}", type);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-generate title if not set
|
||||
String currentTitle = (String) item.get("TITLE");
|
||||
if (currentTitle == null || currentTitle.isBlank()) {
|
||||
String autoTitle = generateTitle(extractedText, modelId);
|
||||
knowledgeRepository.updateTitle(knowledgeItemId, autoTitle);
|
||||
}
|
||||
|
||||
// Step 2: Chunk
|
||||
knowledgeRepository.updateStatus(knowledgeItemId, "CHUNKING");
|
||||
List<String> chunks = chunkingService.chunk(extractedText);
|
||||
log.info("Item {} chunked into {} pieces", knowledgeItemId, chunks.size());
|
||||
|
||||
for (int i = 0; i < chunks.size(); i++) {
|
||||
String chunkContent = chunks.get(i);
|
||||
int tokenCount = chunkingService.estimateTokenCount(chunkContent);
|
||||
chunkRepository.insertChunk(knowledgeItemId, i, chunkContent, tokenCount);
|
||||
}
|
||||
|
||||
// Step 3: Categorize
|
||||
knowledgeRepository.updateStatus(knowledgeItemId, "CATEGORIZING");
|
||||
categorize(knowledgeItemId, (String) item.get("USER_ID"), extractedText, modelId);
|
||||
|
||||
// Step 4: Embedding
|
||||
knowledgeRepository.updateStatus(knowledgeItemId, "EMBEDDING");
|
||||
embedChunks(knowledgeItemId, chunks);
|
||||
|
||||
// Done
|
||||
knowledgeRepository.updateStatus(knowledgeItemId, "READY");
|
||||
log.info("Pipeline complete for item {}: {} chunks stored", knowledgeItemId, chunks.size());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Pipeline failed for item {}", knowledgeItemId, e);
|
||||
try {
|
||||
knowledgeRepository.updateStatus(knowledgeItemId, "FAILED");
|
||||
} catch (Exception ex) {
|
||||
log.error("Failed to update status to FAILED", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.sundol.dto.IngestRequest;
|
||||
import com.sundol.exception.AppException;
|
||||
import com.sundol.repository.CategoryRepository;
|
||||
import com.sundol.repository.KnowledgeChunkRepository;
|
||||
import com.sundol.repository.KnowledgeRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -12,38 +17,77 @@ import java.util.Map;
|
||||
public class KnowledgeService {
|
||||
|
||||
private final KnowledgeRepository knowledgeRepository;
|
||||
private final KnowledgeChunkRepository chunkRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final IngestPipelineService pipelineService;
|
||||
|
||||
public KnowledgeService(KnowledgeRepository knowledgeRepository) {
|
||||
public KnowledgeService(
|
||||
KnowledgeRepository knowledgeRepository,
|
||||
KnowledgeChunkRepository chunkRepository,
|
||||
CategoryRepository categoryRepository,
|
||||
IngestPipelineService pipelineService) {
|
||||
this.knowledgeRepository = knowledgeRepository;
|
||||
this.chunkRepository = chunkRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.pipelineService = pipelineService;
|
||||
}
|
||||
|
||||
public Mono<List<Map<String, Object>>> list(String userId, String type, String status, String tag, String search) {
|
||||
// TODO: Query knowledge items with filters
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> knowledgeRepository.list(userId, type, status, search))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Map<String, Object>> ingest(String userId, IngestRequest request) {
|
||||
// TODO: Create knowledge item, trigger async ingest pipeline
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
String id = knowledgeRepository.insert(
|
||||
userId, request.type(), request.title(), request.url(), request.rawText()
|
||||
);
|
||||
// Trigger async pipeline
|
||||
pipelineService.runPipeline(id, request.modelId());
|
||||
|
||||
return Map.of(
|
||||
"id", (Object) id,
|
||||
"status", "PENDING"
|
||||
);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Map<String, Object>> getById(String userId, String id) {
|
||||
// TODO: Get knowledge item by ID
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> item = knowledgeRepository.findById(userId, id);
|
||||
if (item == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
|
||||
}
|
||||
item.put("CATEGORIES", categoryRepository.findByKnowledgeItemId(id));
|
||||
return item;
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
|
||||
// TODO: Update knowledge item fields
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> item = knowledgeRepository.findById(userId, id);
|
||||
if (item == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
|
||||
}
|
||||
if (updates.containsKey("title")) {
|
||||
knowledgeRepository.updateTitle(id, (String) updates.get("title"));
|
||||
}
|
||||
return knowledgeRepository.findById(userId, id);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Void> delete(String userId, String id) {
|
||||
// TODO: Delete knowledge item and all chunks
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromRunnable(() -> knowledgeRepository.delete(userId, id))
|
||||
.subscribeOn(Schedulers.boundedElastic()).then();
|
||||
}
|
||||
|
||||
public Mono<List<Map<String, Object>>> getChunks(String userId, String id) {
|
||||
// TODO: List chunks for knowledge item
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> item = knowledgeRepository.findById(userId, id);
|
||||
if (item == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
|
||||
}
|
||||
return chunkRepository.findByKnowledgeItemId(id);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class OciGenAiService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OciGenAiService.class);
|
||||
|
||||
private final String apiKey;
|
||||
private final String compartment;
|
||||
private final String defaultModel;
|
||||
private final String baseUrl;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public static final List<Map<String, String>> AVAILABLE_MODELS = List.of(
|
||||
Map.of("id", "google.gemini-2.5-pro", "name", "Gemini 2.5 Pro", "vendor", "Google"),
|
||||
Map.of("id", "google.gemini-2.5-flash", "name", "Gemini 2.5 Flash", "vendor", "Google"),
|
||||
Map.of("id", "google.gemini-2.5-flash-lite", "name", "Gemini 2.5 Flash Lite", "vendor", "Google"),
|
||||
Map.of("id", "xai.grok-4.20-reasoning", "name", "Grok 4.20 Reasoning", "vendor", "xAI"),
|
||||
Map.of("id", "xai.grok-4.20-non-reasoning", "name", "Grok 4.20 Non-Reasoning", "vendor", "xAI"),
|
||||
Map.of("id", "xai.grok-4-1-fast-reasoning", "name", "Grok 4-1 Fast Reasoning", "vendor", "xAI"),
|
||||
Map.of("id", "xai.grok-4-1-fast-non-reasoning", "name", "Grok 4-1 Fast Non-Reasoning", "vendor", "xAI"),
|
||||
Map.of("id", "xai.grok-4", "name", "Grok 4", "vendor", "xAI"),
|
||||
Map.of("id", "xai.grok-3", "name", "Grok 3", "vendor", "xAI"),
|
||||
Map.of("id", "xai.grok-3-mini", "name", "Grok 3 Mini", "vendor", "xAI"),
|
||||
Map.of("id", "openai.gpt-oss-120b", "name", "GPT-OSS 120B", "vendor", "OpenAI"),
|
||||
Map.of("id", "openai.gpt-oss-20b", "name", "GPT-OSS 20B", "vendor", "OpenAI"),
|
||||
Map.of("id", "cohere.command-a-03-2025", "name", "Command A", "vendor", "Cohere"),
|
||||
Map.of("id", "cohere.command-a-reasoning", "name", "Command A Reasoning", "vendor", "Cohere"),
|
||||
Map.of("id", "cohere.command-r-plus-08-2024", "name", "Command R+", "vendor", "Cohere"),
|
||||
Map.of("id", "meta.llama-4-maverick-17b-128e-instruct-fp8", "name", "Llama 4 Maverick 17B", "vendor", "Meta"),
|
||||
Map.of("id", "meta.llama-4-scout-17b-16e-instruct", "name", "Llama 4 Scout 17B", "vendor", "Meta")
|
||||
);
|
||||
|
||||
public OciGenAiService(
|
||||
@Value("${oci.genai.api-key:}") String apiKey,
|
||||
@Value("${oci.genai.compartment:}") String compartment,
|
||||
@Value("${oci.genai.model:google.gemini-2.5-flash}") String defaultModel,
|
||||
@Value("${oci.genai.base-url:}") String baseUrl,
|
||||
ObjectMapper objectMapper) {
|
||||
this.apiKey = apiKey;
|
||||
this.compartment = compartment;
|
||||
this.defaultModel = defaultModel;
|
||||
this.baseUrl = baseUrl;
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(30))
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean isConfigured() {
|
||||
return apiKey != null && !apiKey.isBlank()
|
||||
&& compartment != null && !compartment.isBlank();
|
||||
}
|
||||
|
||||
public String getDefaultModel() {
|
||||
return defaultModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* OCI GenAI Chat API 호출.
|
||||
*/
|
||||
public String chat(String systemMessage, String userMessage, String modelId) throws Exception {
|
||||
if (!isConfigured()) {
|
||||
throw new IllegalStateException("OCI GenAI is not configured");
|
||||
}
|
||||
if (modelId == null || modelId.isBlank()) {
|
||||
modelId = defaultModel;
|
||||
}
|
||||
|
||||
Map<String, Object> payload = Map.of(
|
||||
"compartmentId", compartment,
|
||||
"servingMode", Map.of(
|
||||
"servingType", "ON_DEMAND",
|
||||
"modelId", modelId
|
||||
),
|
||||
"chatRequest", Map.of(
|
||||
"apiFormat", "GENERIC",
|
||||
"messages", List.of(
|
||||
Map.of("role", "SYSTEM", "content", List.of(Map.of("type", "TEXT", "text", systemMessage))),
|
||||
Map.of("role", "USER", "content", List.of(Map.of("type", "TEXT", "text", userMessage)))
|
||||
),
|
||||
"maxTokens", 4096,
|
||||
"temperature", 0.3
|
||||
)
|
||||
);
|
||||
|
||||
String body = objectMapper.writeValueAsString(payload);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/chat"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.timeout(Duration.ofSeconds(120))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
log.error("OCI GenAI error {} (model={}): {}", response.statusCode(), modelId,
|
||||
response.body().substring(0, Math.min(response.body().length(), 500)));
|
||||
throw new RuntimeException("OCI GenAI returned " + response.statusCode());
|
||||
}
|
||||
|
||||
JsonNode root = objectMapper.readTree(response.body());
|
||||
JsonNode choices = root.path("chatResponse").path("choices");
|
||||
if (choices.isArray() && !choices.isEmpty()) {
|
||||
JsonNode content = choices.get(0).path("message").path("content");
|
||||
if (content.isArray() && !content.isEmpty()) {
|
||||
return content.get(0).path("text").asText("");
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException("Unexpected OCI GenAI response structure");
|
||||
}
|
||||
|
||||
private static final String EMBED_MODEL = "cohere.embed-v4.0";
|
||||
private static final int EMBED_BATCH_SIZE = 96;
|
||||
|
||||
/**
|
||||
* OCI GenAI Embed API 호출. 최대 96개씩 배치 처리.
|
||||
* @param texts 임베딩할 텍스트 목록
|
||||
* @param inputType SEARCH_DOCUMENT (저장 시) 또는 SEARCH_QUERY (검색 시)
|
||||
* @return 각 텍스트에 대응하는 float[] 벡터 리스트
|
||||
*/
|
||||
public List<float[]> embedTexts(List<String> texts, String inputType) throws Exception {
|
||||
if (!isConfigured()) {
|
||||
throw new IllegalStateException("OCI GenAI is not configured");
|
||||
}
|
||||
|
||||
List<float[]> allEmbeddings = new ArrayList<>();
|
||||
|
||||
for (int start = 0; start < texts.size(); start += EMBED_BATCH_SIZE) {
|
||||
int end = Math.min(start + EMBED_BATCH_SIZE, texts.size());
|
||||
List<String> batch = texts.subList(start, end);
|
||||
|
||||
Map<String, Object> payload = Map.of(
|
||||
"compartmentId", compartment,
|
||||
"servingMode", Map.of(
|
||||
"servingType", "ON_DEMAND",
|
||||
"modelId", EMBED_MODEL
|
||||
),
|
||||
"inputs", batch,
|
||||
"inputType", inputType,
|
||||
"truncate", "END"
|
||||
);
|
||||
|
||||
String body = objectMapper.writeValueAsString(payload);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/embedText"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.timeout(Duration.ofSeconds(120))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
log.error("OCI GenAI embed error {}: {}", response.statusCode(),
|
||||
response.body().substring(0, Math.min(response.body().length(), 500)));
|
||||
throw new RuntimeException("OCI GenAI embed returned " + response.statusCode());
|
||||
}
|
||||
|
||||
JsonNode root = objectMapper.readTree(response.body());
|
||||
JsonNode embeddings = root.path("embeddings");
|
||||
if (!embeddings.isArray()) {
|
||||
throw new RuntimeException("Unexpected embed response: no embeddings array");
|
||||
}
|
||||
|
||||
for (JsonNode embNode : embeddings) {
|
||||
float[] vec = new float[embNode.size()];
|
||||
for (int i = 0; i < embNode.size(); i++) {
|
||||
vec[i] = (float) embNode.get(i).asDouble();
|
||||
}
|
||||
allEmbeddings.add(vec);
|
||||
}
|
||||
|
||||
log.debug("Embedded batch [{}-{}] of {} texts", start, end, texts.size());
|
||||
}
|
||||
|
||||
return allEmbeddings;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.sundol.repository.KnowledgeChunkRepository;
|
||||
import com.sundol.repository.ChunkEmbeddingRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -10,14 +13,24 @@ import java.util.Map;
|
||||
@Service
|
||||
public class SearchService {
|
||||
|
||||
private final KnowledgeChunkRepository chunkRepository;
|
||||
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
|
||||
|
||||
public SearchService(KnowledgeChunkRepository chunkRepository) {
|
||||
this.chunkRepository = chunkRepository;
|
||||
private final ChunkEmbeddingRepository embeddingRepository;
|
||||
private final OciGenAiService genAiService;
|
||||
|
||||
public SearchService(ChunkEmbeddingRepository embeddingRepository, OciGenAiService genAiService) {
|
||||
this.embeddingRepository = embeddingRepository;
|
||||
this.genAiService = genAiService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시맨틱 검색: 쿼리를 임베딩 → VECTOR_DISTANCE로 유사 청크 검색
|
||||
*/
|
||||
public Mono<List<Map<String, Object>>> search(String userId, String query, int topK) {
|
||||
// TODO: Embed query via OCI GenAI, VECTOR_DISTANCE search
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
List<float[]> embeddings = genAiService.embedTexts(List.of(query), "SEARCH_QUERY");
|
||||
float[] queryVector = embeddings.get(0);
|
||||
return embeddingRepository.searchSimilar(userId, queryVector, topK);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sundol.exception.AppException;
|
||||
import com.sundol.repository.KnowledgeChunkRepository;
|
||||
import com.sundol.repository.KnowledgeRepository;
|
||||
import com.sundol.repository.StudyCardRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -10,29 +19,137 @@ import java.util.Map;
|
||||
@Service
|
||||
public class StudyCardService {
|
||||
|
||||
private final StudyCardRepository studyCardRepository;
|
||||
private static final Logger log = LoggerFactory.getLogger(StudyCardService.class);
|
||||
|
||||
public StudyCardService(StudyCardRepository studyCardRepository) {
|
||||
private final StudyCardRepository studyCardRepository;
|
||||
private final KnowledgeRepository knowledgeRepository;
|
||||
private final KnowledgeChunkRepository chunkRepository;
|
||||
private final OciGenAiService genAiService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public StudyCardService(
|
||||
StudyCardRepository studyCardRepository,
|
||||
KnowledgeRepository knowledgeRepository,
|
||||
KnowledgeChunkRepository chunkRepository,
|
||||
OciGenAiService genAiService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.studyCardRepository = studyCardRepository;
|
||||
this.knowledgeRepository = knowledgeRepository;
|
||||
this.chunkRepository = chunkRepository;
|
||||
this.genAiService = genAiService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public Mono<List<Map<String, Object>>> getDueCards(String userId) {
|
||||
// TODO: Get cards due for review today
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> studyCardRepository.getDueCards(userId))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<List<Map<String, Object>>> getByKnowledgeItem(String userId, String knowledgeItemId) {
|
||||
// TODO: Get cards for a specific knowledge item
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> studyCardRepository.getByKnowledgeItem(userId, knowledgeItemId))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge item의 청크들을 기반으로 LLM이 Q&A 카드를 자동 생성.
|
||||
*/
|
||||
public Mono<Map<String, Object>> generate(String userId, String knowledgeItemId) {
|
||||
// TODO: Trigger AI card generation from knowledge item
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> item = knowledgeRepository.findById(userId, knowledgeItemId);
|
||||
if (item == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
|
||||
}
|
||||
if (!"READY".equals(item.get("STATUS"))) {
|
||||
throw new AppException(HttpStatus.BAD_REQUEST, "Knowledge item is not ready yet");
|
||||
}
|
||||
|
||||
List<Map<String, Object>> chunks = chunkRepository.findByKnowledgeItemId(knowledgeItemId);
|
||||
if (chunks.isEmpty()) {
|
||||
throw new AppException(HttpStatus.BAD_REQUEST, "No chunks available");
|
||||
}
|
||||
|
||||
// 청크 내용을 합쳐서 LLM에 전달 (최대 4000자)
|
||||
StringBuilder content = new StringBuilder();
|
||||
for (Map<String, Object> chunk : chunks) {
|
||||
Object c = chunk.get("CONTENT");
|
||||
if (c != null) {
|
||||
content.append(c.toString()).append("\n\n");
|
||||
}
|
||||
if (content.length() > 4000) break;
|
||||
}
|
||||
|
||||
String systemMsg =
|
||||
"You are a study card generator. Create flashcards from the given content. " +
|
||||
"Each card has a 'front' (question) and 'back' (answer). " +
|
||||
"Generate 3-5 cards that test key concepts. " +
|
||||
"Use the same language as the content. " +
|
||||
"Respond with ONLY a JSON array: [{\"front\":\"question\",\"back\":\"answer\"},...]. " +
|
||||
"No markdown, no explanation.";
|
||||
String userMsg = "Generate study cards from this content:\n\n" + content;
|
||||
|
||||
String response = genAiService.chat(systemMsg, userMsg, null);
|
||||
response = response.replaceAll("```json\\s*", "").replaceAll("```\\s*", "").strip();
|
||||
|
||||
List<Map<String, String>> cards = objectMapper.readValue(response, new TypeReference<>() {});
|
||||
|
||||
int created = 0;
|
||||
for (Map<String, String> card : cards) {
|
||||
String front = card.get("front");
|
||||
String back = card.get("back");
|
||||
if (front != null && back != null && !front.isBlank() && !back.isBlank()) {
|
||||
studyCardRepository.insert(userId, knowledgeItemId, front, back);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Generated {} study cards for knowledge item {}", created, knowledgeItemId);
|
||||
return Map.of("generated", (Object) created, "knowledgeItemId", knowledgeItemId);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/**
|
||||
* SM-2 알고리즘으로 카드 복습 결과 처리.
|
||||
* rating: 0(완전 모름) ~ 5(완벽)
|
||||
*/
|
||||
public Mono<Map<String, Object>> review(String userId, String id, int rating) {
|
||||
// TODO: Apply SM-2 algorithm and update card
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> card = studyCardRepository.findById(userId, id);
|
||||
if (card == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Study card not found");
|
||||
}
|
||||
|
||||
Number efNum = (Number) card.get("EASE_FACTOR");
|
||||
Number ivNum = (Number) card.get("INTERVAL_DAYS");
|
||||
Number repNum = (Number) card.get("REPETITIONS");
|
||||
|
||||
double easeFactor = efNum != null ? efNum.doubleValue() : 2.5;
|
||||
int intervalDays = ivNum != null ? ivNum.intValue() : 0;
|
||||
int repetitions = repNum != null ? repNum.intValue() : 0;
|
||||
|
||||
// SM-2 알고리즘
|
||||
if (rating < 3) {
|
||||
// 실패: 처음부터 다시
|
||||
repetitions = 0;
|
||||
intervalDays = 1;
|
||||
} else {
|
||||
// 성공
|
||||
repetitions++;
|
||||
if (repetitions == 1) {
|
||||
intervalDays = 1;
|
||||
} else if (repetitions == 2) {
|
||||
intervalDays = 6;
|
||||
} else {
|
||||
intervalDays = (int) Math.round(intervalDays * easeFactor);
|
||||
}
|
||||
}
|
||||
|
||||
// Ease Factor 갱신
|
||||
easeFactor = easeFactor + (0.1 - (5 - rating) * (0.08 + (5 - rating) * 0.02));
|
||||
if (easeFactor < 1.3) easeFactor = 1.3;
|
||||
|
||||
studyCardRepository.updateSm2(id, easeFactor, intervalDays, repetitions, intervalDays);
|
||||
|
||||
return studyCardRepository.findById(userId, id);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.sundol.dto.TagRequest;
|
||||
import com.sundol.exception.AppException;
|
||||
import com.sundol.repository.TagRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -18,22 +21,41 @@ public class TagService {
|
||||
}
|
||||
|
||||
public Mono<List<Map<String, Object>>> list(String userId) {
|
||||
// TODO: List tags for user
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> tagRepository.list(userId))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Map<String, Object>> create(String userId, TagRequest request) {
|
||||
// TODO: Create tag
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
String color = request.color() != null ? request.color() : "#6366f1";
|
||||
String id = tagRepository.insert(userId, request.name(), color);
|
||||
return tagRepository.findById(userId, id);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Map<String, Object>> update(String userId, String id, TagRequest request) {
|
||||
// TODO: Update tag
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> tag = tagRepository.findById(userId, id);
|
||||
if (tag == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Tag not found");
|
||||
}
|
||||
if (request.name() != null) {
|
||||
tagRepository.updateName(id, request.name());
|
||||
}
|
||||
if (request.color() != null) {
|
||||
tagRepository.updateColor(id, request.color());
|
||||
}
|
||||
return tagRepository.findById(userId, id);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Void> delete(String userId, String id) {
|
||||
// TODO: Delete tag and remove from items
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromRunnable(() -> {
|
||||
Map<String, Object> tag = tagRepository.findById(userId, id);
|
||||
if (tag == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Tag not found");
|
||||
}
|
||||
tagRepository.delete(userId, id);
|
||||
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.sundol.dto.TodoRequest;
|
||||
import com.sundol.exception.AppException;
|
||||
import com.sundol.repository.TodoRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -18,27 +21,72 @@ public class TodoService {
|
||||
}
|
||||
|
||||
public Mono<List<Map<String, Object>>> list(String userId, String status, String priority, String dueDate) {
|
||||
// TODO: List todos with filters
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
List<Map<String, Object>> todos = todoRepository.list(userId, status, priority, dueDate);
|
||||
// 각 todo에 서브태스크 개수 포함
|
||||
for (Map<String, Object> todo : todos) {
|
||||
String todoId = (String) todo.get("ID");
|
||||
List<Map<String, Object>> subtasks = todoRepository.findSubtasks(userId, todoId);
|
||||
todo.put("SUBTASK_COUNT", subtasks.size());
|
||||
long doneCount = subtasks.stream()
|
||||
.filter(s -> "DONE".equals(s.get("STATUS")))
|
||||
.count();
|
||||
todo.put("SUBTASK_DONE_COUNT", doneCount);
|
||||
}
|
||||
return todos;
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Map<String, Object>> create(String userId, TodoRequest request) {
|
||||
// TODO: Create todo
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
String priority = request.priority() != null ? request.priority() : "MEDIUM";
|
||||
String id = todoRepository.insert(
|
||||
userId, request.title(), request.description(),
|
||||
priority, request.dueDate(), request.parentId()
|
||||
);
|
||||
return todoRepository.findById(userId, id);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
|
||||
// TODO: Update todo fields
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> todo = todoRepository.findById(userId, id);
|
||||
if (todo == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Todo not found");
|
||||
}
|
||||
if (updates.containsKey("status")) {
|
||||
todoRepository.updateStatus(id, (String) updates.get("status"));
|
||||
}
|
||||
if (updates.containsKey("title")) {
|
||||
todoRepository.updateTitle(id, (String) updates.get("title"));
|
||||
}
|
||||
if (updates.containsKey("priority")) {
|
||||
todoRepository.updatePriority(id, (String) updates.get("priority"));
|
||||
}
|
||||
if (updates.containsKey("dueDate")) {
|
||||
todoRepository.updateDueDate(id, (String) updates.get("dueDate"));
|
||||
}
|
||||
return todoRepository.findById(userId, id);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<Void> delete(String userId, String id) {
|
||||
// TODO: Delete todo and subtasks
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromRunnable(() -> {
|
||||
Map<String, Object> todo = todoRepository.findById(userId, id);
|
||||
if (todo == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Todo not found");
|
||||
}
|
||||
todoRepository.delete(userId, id);
|
||||
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||
}
|
||||
|
||||
public Mono<List<Map<String, Object>>> getSubtasks(String userId, String id) {
|
||||
// TODO: List subtasks for todo
|
||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> todo = todoRepository.findById(userId, id);
|
||||
if (todo == null) {
|
||||
throw new AppException(HttpStatus.NOT_FOUND, "Todo not found");
|
||||
}
|
||||
return todoRepository.findSubtasks(userId, id);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Service
|
||||
public class WebCrawlerService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WebCrawlerService.class);
|
||||
|
||||
public String crawl(String url) throws IOException {
|
||||
log.info("Crawling URL: {}", url);
|
||||
Document doc = Jsoup.connect(url)
|
||||
.userAgent("Mozilla/5.0 (compatible; SUNDOL-bot/1.0)")
|
||||
.timeout(15_000)
|
||||
.followRedirects(true)
|
||||
.get();
|
||||
|
||||
// Remove non-content elements
|
||||
doc.select("nav, footer, header, script, style, .ad, #cookie-banner, .sidebar, .comments").remove();
|
||||
|
||||
// Prefer article body
|
||||
Element article = doc.selectFirst("article, main, .post-content, .article-body, .entry-content");
|
||||
String text = (article != null ? article : doc.body()).text();
|
||||
|
||||
// Extract title if available
|
||||
String title = doc.title();
|
||||
log.info("Crawled '{}' - {} chars", title, text.length());
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
public String extractTitle(String url) throws IOException {
|
||||
Document doc = Jsoup.connect(url)
|
||||
.userAgent("Mozilla/5.0 (compatible; SUNDOL-bot/1.0)")
|
||||
.timeout(10_000)
|
||||
.get();
|
||||
return doc.title();
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,22 @@ jwt:
|
||||
access-token-expiry: ${JWT_ACCESS_TOKEN_EXPIRY:900000}
|
||||
refresh-token-expiry: ${JWT_REFRESH_TOKEN_EXPIRY:604800000}
|
||||
|
||||
google:
|
||||
client-id: ${GOOGLE_CLIENT_ID}
|
||||
client-secret: ${GOOGLE_CLIENT_SECRET}
|
||||
redirect-uri: ${GOOGLE_REDIRECT_URI:https://sundol.cloud-handson.com/login/callback}
|
||||
|
||||
cors:
|
||||
origin: ${CORS_ORIGIN:http://localhost:3000}
|
||||
|
||||
oci:
|
||||
compartment-id: ${OCI_COMPARTMENT_ID:}
|
||||
region: ${OCI_REGION:ap-seoul-1}
|
||||
genai:
|
||||
api-key: ${OCI_GENAI_API_KEY:}
|
||||
compartment: ${OCI_GENAI_COMPARTMENT:}
|
||||
model: ${OCI_GENAI_MODEL:google.gemini-2.5-flash}
|
||||
base-url: ${OCI_GENAI_BASE_URL:https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions}
|
||||
|
||||
logging:
|
||||
level:
|
||||
|
||||
Reference in New Issue
Block a user