From df9c6a6b5089ffb12e1e02a376d25a6f48efdd21 Mon Sep 17 00:00:00 2001 From: joungmin Date: Wed, 1 Jul 2026 04:09:21 +0000 Subject: [PATCH] =?UTF-8?q?memo:=20=EB=91=90=EC=84=9C=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EB=A9=94=EB=AA=A8=20=E2=86=92=20LLM(PARA+GTD)=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=A0=95=EB=A6=AC=C2=B7=EB=B6=84=EB=A5=98=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- db/migration/V2__memo_tags_project.sql | 15 ++ .../com/sundol/controller/NoteController.java | 61 ++++++- .../com/sundol/repository/NoteRepository.java | 53 ++++++ .../java/com/sundol/service/MemoService.java | 172 ++++++++++++++++++ sundol-frontend/src/app/notes/new/page.tsx | 153 +++++++++++++++- 5 files changed, 444 insertions(+), 10 deletions(-) create mode 100644 db/migration/V2__memo_tags_project.sql create mode 100644 sundol-backend/src/main/java/com/sundol/service/MemoService.java diff --git a/db/migration/V2__memo_tags_project.sql b/db/migration/V2__memo_tags_project.sql new file mode 100644 index 0000000..4ee87b0 --- /dev/null +++ b/db/migration/V2__memo_tags_project.sql @@ -0,0 +1,15 @@ +-- 메모 자동정리·분류(PARA+GTD) 기능용 스키마 +-- 2026-07-01 적용 (SQLcl로 수동 반영됨) + +-- notes에 프로젝트 컬럼 추가 +ALTER TABLE notes ADD (project VARCHAR2(200)); + +-- 노트 검색 태그 테이블 (GTD 액션 태그: next-action/waiting/someday/reference 포함) +CREATE TABLE note_tags ( + note_id RAW(16) NOT NULL, + tag VARCHAR2(100) NOT NULL, + CONSTRAINT fk_note_tags_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, + CONSTRAINT uq_note_tags UNIQUE (note_id, tag) +); +CREATE INDEX idx_note_tags_note ON note_tags(note_id); +CREATE INDEX idx_note_tags_tag ON note_tags(tag); diff --git a/sundol-backend/src/main/java/com/sundol/controller/NoteController.java b/sundol-backend/src/main/java/com/sundol/controller/NoteController.java index 82ed969..f6e1137 100644 --- a/sundol-backend/src/main/java/com/sundol/controller/NoteController.java +++ b/sundol-backend/src/main/java/com/sundol/controller/NoteController.java @@ -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>> organize( + @AuthenticationPrincipal String userId, + @RequestBody Map 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 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>> saveMemo( + @AuthenticationPrincipal String userId, + @RequestBody Map 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 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.of("id", id)); + }).subscribeOn(Schedulers.boundedElastic()); + } + + private static String str(Object o) { + return o == null ? "" : o.toString(); + } + @PatchMapping("/{id}") public Mono>> update( @AuthenticationPrincipal String userId, diff --git a/sundol-backend/src/main/java/com/sundol/repository/NoteRepository.java b/sundol-backend/src/main/java/com/sundol/repository/NoteRepository.java index 323ce7f..f515091 100644 --- a/sundol-backend/src/main/java/com/sundol/repository/NoteRepository.java +++ b/sundol-backend/src/main/java/com/sundol/repository/NoteRepository.java @@ -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 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> list(String userId, String categoryId) { if (categoryId != null && !categoryId.isBlank()) { return jdbcTemplate.queryForList( diff --git a/sundol-backend/src/main/java/com/sundol/service/MemoService.java b/sundol-backend/src/main/java/com/sundol/service/MemoService.java new file mode 100644 index 0000000..1cfc866 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/service/MemoService.java @@ -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 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 existingCats = new ArrayList<>(); + for (Map 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 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 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; + } +} diff --git a/sundol-frontend/src/app/notes/new/page.tsx b/sundol-frontend/src/app/notes/new/page.tsx index 5a72512..6887a36 100644 --- a/sundol-frontend/src/app/notes/new/page.tsx +++ b/sundol-frontend/src/app/notes/new/page.tsx @@ -28,6 +28,15 @@ function NewNotePage() { const [transcription, setTranscription] = useState(""); const fileInputRef = useRef(null); + // AI 정리(PARA+GTD) 상태 + const [organizing, setOrganizing] = useState(false); + const [hasDraft, setHasDraft] = useState(false); + const [category, setCategory] = useState(""); + const [project, setProject] = useState(""); + const [tagsInput, setTagsInput] = useState(""); + const [summary, setSummary] = useState(""); + const [organizedContent, setOrganizedContent] = useState(""); + const handleSaveText = async () => { if (!title.trim() && !content.trim()) return; setSaving(true); @@ -42,6 +51,55 @@ function NewNotePage() { } }; + const handleOrganize = async () => { + if (!content.trim()) return; + setOrganizing(true); + try { + const draft = await request<{ + category: string; + title: string; + summary: string; + content: string; + tags: string[]; + project: string | null; + }>({ method: "POST", url: "/api/notes/organize", data: { content } }); + setTitle(draft.title || ""); + setCategory(draft.category || ""); + setProject(draft.project || ""); + setTagsInput((draft.tags || []).join(", ")); + setSummary(draft.summary || ""); + setOrganizedContent(draft.content || ""); + setHasDraft(true); + } catch (err) { + console.error("Failed to organize memo:", err); + alert("메모 정리에 실패했습니다."); + } finally { + setOrganizing(false); + } + }; + + const handleSaveMemo = async () => { + if (!category.trim()) return; + setSaving(true); + try { + const tags = tagsInput + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + const res = await request<{ id: string }>({ + method: "POST", + url: "/api/notes/memo", + data: { category, title, summary, content: organizedContent, tags, project: project || null }, + }); + router.push(`/notes/${res.id}`); + } catch (err) { + console.error("Failed to save memo:", err); + alert("정리본 저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + const handleUploadAudio = async () => { if (!audioFile) return; setTranscribing(true); @@ -146,17 +204,94 @@ function NewNotePage() {