memo: 두서없는 메모 → LLM(PARA+GTD) 자동정리·분류 저장
- MemoService(save_memo tool): organize(정리·분류→구조화 JSON) + saveMemo(카테고리 findOrCreate + 노트+태그 적재) 분리 설계. GUI/외부API/향후 MCP 공통 사용. - 분류 체계: PARA(카테고리 경로) + GTD(next-action/waiting/someday/reference 태그) 하이브리드. - NoteController: POST /api/notes/organize (미리보기, save=true면 원샷 저장), POST /api/notes/memo (구조화 필드 저장). - NoteRepository: insertMemo(project 포함), addTag/findTags. - DB: notes.project 컬럼 + note_tags 테이블 (V2 마이그레이션). - 프론트: notes/new 텍스트 모드에 'AI로 정리' → 미리보기(카테고리/제목/태그/ 프로젝트/요약/본문 수정) → 정리본 저장 흐름 추가. - LLM은 OCI GenAI(현재 gpt-5.6-terra) 구조화 JSON 출력 방식(tool-calling 의존 없음). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,12 +41,15 @@ public class NoteController {
|
||||
private final NoteRepository noteRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final com.sundol.service.OciGenAiService genAiService;
|
||||
private final com.sundol.service.MemoService memoService;
|
||||
|
||||
public NoteController(NoteRepository noteRepository, CategoryRepository categoryRepository,
|
||||
com.sundol.service.OciGenAiService genAiService) {
|
||||
com.sundol.service.OciGenAiService genAiService,
|
||||
com.sundol.service.MemoService memoService) {
|
||||
this.noteRepository = noteRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.genAiService = genAiService;
|
||||
this.memoService = memoService;
|
||||
try { Files.createDirectories(AUDIO_DIR); } catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
@@ -83,6 +86,62 @@ public class NoteController {
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/**
|
||||
* 두서없는 메모를 LLM(PARA+GTD)으로 정리·분류한다. save=true면 저장까지 수행한다(API 원샷).
|
||||
*/
|
||||
@PostMapping("/organize")
|
||||
public Mono<ResponseEntity<Map<String, Object>>> organize(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
return Mono.fromCallable(() -> {
|
||||
String content = str(body.get("content"));
|
||||
boolean save = Boolean.TRUE.equals(body.get("save"))
|
||||
|| "true".equalsIgnoreCase(String.valueOf(body.get("save")));
|
||||
com.sundol.service.MemoService.MemoDraft draft = memoService.organize(userId, content);
|
||||
Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("category", draft.category());
|
||||
result.put("title", draft.title());
|
||||
result.put("summary", draft.summary());
|
||||
result.put("content", draft.content());
|
||||
result.put("tags", draft.tags());
|
||||
result.put("project", draft.project());
|
||||
if (save) {
|
||||
String id = memoService.saveMemo(userId, draft.category(), draft.title(),
|
||||
draft.summary(), draft.content(), draft.tags(), draft.project());
|
||||
result.put("id", id);
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/**
|
||||
* 정리된 메모를 저장한다(save_memo). GUI가 미리보기를 수정 후 호출하거나 외부에서 직접 호출.
|
||||
*/
|
||||
@PostMapping("/memo")
|
||||
public Mono<ResponseEntity<Map<String, Object>>> saveMemo(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
return Mono.fromCallable(() -> {
|
||||
String category = str(body.get("category"));
|
||||
String title = str(body.get("title"));
|
||||
String summary = str(body.get("summary"));
|
||||
String content = str(body.get("content"));
|
||||
String project = body.get("project") != null ? str(body.get("project")) : null;
|
||||
List<String> tags = new ArrayList<>();
|
||||
if (body.get("tags") instanceof List<?> list) {
|
||||
for (Object o : list) {
|
||||
if (o != null && !o.toString().isBlank()) tags.add(o.toString());
|
||||
}
|
||||
}
|
||||
String id = memoService.saveMemo(userId, category, title, summary, content, tags, project);
|
||||
return ResponseEntity.ok(Map.<String, Object>of("id", id));
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? "" : o.toString();
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
public Mono<ResponseEntity<Map<String, Object>>> update(
|
||||
@AuthenticationPrincipal String userId,
|
||||
|
||||
@@ -39,6 +39,59 @@ public class NoteRepository {
|
||||
return (String) result.get(0).get("ID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 정리된 메모를 저장한다. project 컬럼을 포함하며 note_type은 TEXT로 저장한다.
|
||||
*/
|
||||
public String insertMemo(String userId, String title, String content, String categoryId, String project) {
|
||||
if (categoryId != null) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO notes (id, user_id, title, content, note_type, category_id, project, created_at, updated_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, 'TEXT', HEXTORAW(?), ?, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||
new Object[]{userId, title, content, categoryId, project},
|
||||
new int[]{java.sql.Types.VARCHAR, java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR}
|
||||
);
|
||||
} else {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO notes (id, user_id, title, content, note_type, project, created_at, updated_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, 'TEXT', ?, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||
new Object[]{userId, title, content, project},
|
||||
new int[]{java.sql.Types.VARCHAR, java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR}
|
||||
);
|
||||
}
|
||||
var result = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id FROM notes WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||
userId
|
||||
);
|
||||
return (String) result.get(0).get("ID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 노트에 태그를 추가한다. (note_id, tag) 중복이면 무시한다.
|
||||
*/
|
||||
public void addTag(String noteId, String tag) {
|
||||
Integer cnt = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM note_tags WHERE note_id = HEXTORAW(?) AND tag = ?",
|
||||
Integer.class, noteId, tag
|
||||
);
|
||||
if (cnt == null || cnt == 0) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO note_tags (note_id, tag) VALUES (HEXTORAW(?), ?)",
|
||||
noteId, tag
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 노트의 태그 목록을 조회한다.
|
||||
*/
|
||||
public List<String> findTags(String noteId) {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT tag FROM note_tags WHERE note_id = HEXTORAW(?) ORDER BY tag",
|
||||
(rs, rowNum) -> rs.getString("tag"),
|
||||
noteId
|
||||
);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> list(String userId, String categoryId) {
|
||||
if (categoryId != null && !categoryId.isBlank()) {
|
||||
return jdbcTemplate.queryForList(
|
||||
|
||||
172
sundol-backend/src/main/java/com/sundol/service/MemoService.java
Normal file
172
sundol-backend/src/main/java/com/sundol/service/MemoService.java
Normal file
@@ -0,0 +1,172 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sundol.repository.CategoryRepository;
|
||||
import com.sundol.repository.NoteRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 두서없는 텍스트 메모를 PARA+GTD 기준으로 LLM이 정리·분류하고, "save_memo" tool 형태로 저장한다.
|
||||
* 추출(organize)과 저장(saveMemo)을 분리하여 GUI 미리보기/외부 API/향후 MCP 노출에 공통 사용한다.
|
||||
*/
|
||||
@Service
|
||||
public class MemoService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MemoService.class);
|
||||
|
||||
private final OciGenAiService genAiService;
|
||||
private final NoteRepository noteRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
public MemoService(OciGenAiService genAiService, NoteRepository noteRepository,
|
||||
CategoryRepository categoryRepository) {
|
||||
this.genAiService = genAiService;
|
||||
this.noteRepository = noteRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정리·분류 결과. save_memo tool의 인자와 1:1 대응한다.
|
||||
*/
|
||||
public record MemoDraft(String category, String title, String summary,
|
||||
String content, List<String> tags, String project) {}
|
||||
|
||||
private static final String ORGANIZE_SYSTEM = """
|
||||
당신은 PARA + GTD 기반 메모 정리·분류 전문가입니다.
|
||||
사용자가 두서없이 적은 메모를 읽고, 아래 JSON 객체 "하나만" 출력하세요. 다른 설명/코드펜스 없이 JSON만.
|
||||
|
||||
## 필드
|
||||
- category: 반드시 "Projects", "Areas", "Resources", "Archives" 중 하나로 시작하는 경로(예: "Projects/사이트리뉴얼").
|
||||
(Projects=마감·목표 있는 진행 과제, Areas=지속 책임영역, Resources=참고자료·관심사, Archives=완료·보관)
|
||||
가능하면 아래 "기존 카테고리" 중 적합한 것을 그대로 재사용하세요.
|
||||
- title: 12자 내외의 짧은 제목.
|
||||
- summary: 핵심 요약. 불릿 2~3줄(Markdown).
|
||||
- content: 구조화·교정된 본문(Markdown). 소제목·불릿으로 정리하되 원문의 정보를 빠짐없이 보존하세요.
|
||||
원문에 없는 내용을 추가하거나 추측하지 마세요. 정보를 임의로 삭제하지 마세요.
|
||||
- tags: 검색용 태그 배열. 실행이 필요한 메모면 GTD 액션 태그(next-action, waiting, someday, reference)를 함께 포함하세요.
|
||||
- project: 특정 프로젝트에 속하면 프로젝트명, 아니면 null.
|
||||
|
||||
## 규칙
|
||||
1. 원문과 같은 언어로 작성하세요.
|
||||
2. category는 위 4개 루트 중 하나로 반드시 시작해야 합니다.
|
||||
3. 반드시 유효한 JSON 하나만 출력하세요.
|
||||
|
||||
## 기존 카테고리
|
||||
%s
|
||||
""";
|
||||
|
||||
/**
|
||||
* 메모를 LLM으로 정리·분류하여 구조화된 초안을 반환한다. (DB 저장 안 함)
|
||||
*/
|
||||
public MemoDraft organize(String userId, String rawText) throws Exception {
|
||||
if (rawText == null || rawText.isBlank()) {
|
||||
throw new IllegalArgumentException("정리할 메모 내용이 비어 있습니다.");
|
||||
}
|
||||
if (!genAiService.isConfigured()) {
|
||||
throw new IllegalStateException("OCI GenAI is not configured");
|
||||
}
|
||||
|
||||
List<String> existingCats = new ArrayList<>();
|
||||
for (Map<String, Object> c : categoryRepository.findAllByUser(userId)) {
|
||||
Object fp = c.get("FULL_PATH");
|
||||
if (fp != null && !fp.toString().isBlank()) existingCats.add(fp.toString());
|
||||
}
|
||||
String catList = existingCats.isEmpty() ? "(없음 - 새로 만드세요)" : String.join("\n", existingCats);
|
||||
|
||||
String systemMsg = String.format(ORGANIZE_SYSTEM, catList);
|
||||
String llm = genAiService.chat(systemMsg, "아래 메모를 정리·분류하세요:\n\n" + rawText, null);
|
||||
|
||||
String json = extractJson(llm);
|
||||
JsonNode n;
|
||||
try {
|
||||
n = objectMapper.readTree(json);
|
||||
} catch (Exception e) {
|
||||
log.error("메모 정리 JSON 파싱 실패. 원문 응답: {}", llm.substring(0, Math.min(500, llm.length())));
|
||||
throw new IllegalStateException("LLM 응답을 JSON으로 파싱하지 못했습니다: " + e.getMessage());
|
||||
}
|
||||
|
||||
String category = n.path("category").asText("").strip();
|
||||
if (category.isBlank()) {
|
||||
throw new IllegalStateException("LLM이 category를 반환하지 않았습니다.");
|
||||
}
|
||||
|
||||
List<String> tags = new ArrayList<>();
|
||||
JsonNode tagsNode = n.path("tags");
|
||||
if (tagsNode.isArray()) {
|
||||
for (JsonNode t : tagsNode) {
|
||||
String tag = t.asText("").strip();
|
||||
if (!tag.isBlank()) tags.add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode projNode = n.path("project");
|
||||
String project = (projNode.isNull() || projNode.isMissingNode()) ? null : projNode.asText(null);
|
||||
if (project != null && project.isBlank()) project = null;
|
||||
|
||||
return new MemoDraft(
|
||||
category,
|
||||
n.path("title").asText("").strip(),
|
||||
n.path("summary").asText("").strip(),
|
||||
n.path("content").asText("").strip(),
|
||||
tags,
|
||||
project
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정리된 메모를 저장한다(save_memo tool 본체). 카테고리 경로를 확보하고 노트+태그를 적재한다.
|
||||
* @return 생성된 노트 id
|
||||
*/
|
||||
public String saveMemo(String userId, String category, String title, String summary,
|
||||
String content, List<String> tags, String project) {
|
||||
if (category == null || category.isBlank()) {
|
||||
throw new IllegalArgumentException("category는 필수입니다.");
|
||||
}
|
||||
String categoryId = categoryRepository.findOrCreate(userId, category);
|
||||
|
||||
String safeSummary = summary == null ? "" : summary;
|
||||
String safeContent = content == null ? "" : content;
|
||||
String body = "# 요약\n\n" + safeSummary + "\n\n---\n\n# 본문\n\n" + safeContent
|
||||
+ "\n\n---\n\n*🤖 정리·분류: " + genAiService.getDefaultModel() + "*";
|
||||
|
||||
String noteTitle = (title == null || title.isBlank()) ? "제목 없는 메모" : title;
|
||||
String noteId = noteRepository.insertMemo(userId, noteTitle, body, categoryId, project);
|
||||
|
||||
if (tags != null) {
|
||||
for (String tag : tags) {
|
||||
if (tag != null && !tag.isBlank()) {
|
||||
noteRepository.addTag(noteId, tag.strip());
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("Memo saved: note={} category={} tags={} project={}", noteId, category, tags, project);
|
||||
return noteId;
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 응답에서 JSON 객체 부분만 추출한다. 코드펜스(```)와 앞뒤 잡텍스트를 제거한다.
|
||||
*/
|
||||
private String extractJson(String s) {
|
||||
String t = s.strip();
|
||||
if (t.startsWith("```")) {
|
||||
int firstNl = t.indexOf('\n');
|
||||
if (firstNl >= 0) t = t.substring(firstNl + 1);
|
||||
if (t.endsWith("```")) t = t.substring(0, t.length() - 3);
|
||||
t = t.strip();
|
||||
}
|
||||
int a = t.indexOf('{');
|
||||
int b = t.lastIndexOf('}');
|
||||
if (a >= 0 && b > a) {
|
||||
return t.substring(a, b + 1);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user