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:
2026-07-01 04:09:21 +00:00
parent e1e71dc954
commit df9c6a6b50
5 changed files with 444 additions and 10 deletions

View File

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

View File

@@ -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,

View File

@@ -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(

View 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;
}
}

View File

@@ -28,6 +28,15 @@ function NewNotePage() {
const [transcription, setTranscription] = useState("");
const fileInputRef = useRef<HTMLInputElement>(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,18 +204,95 @@ function NewNotePage() {
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="노트 내용을 입력하세요..."
rows={15}
placeholder="두서없이 적어도 됩니다. 'AI로 정리'를 누르면 카테고리·제목·태그·요약으로 정리해 드립니다..."
rows={12}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y font-mono text-sm"
/>
<div className="mt-4 flex gap-2 flex-wrap">
<button
onClick={handleOrganize}
disabled={organizing || !content.trim()}
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40"
>
{organizing ? "정리 중..." : "✨ AI로 정리"}
</button>
<button
onClick={handleSaveText}
disabled={saving || (!title.trim() && !content.trim())}
className="mt-4 px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40"
className="px-6 py-2 rounded-lg border border-[var(--color-border)] hover:bg-[var(--color-bg-hover)] transition-colors disabled:opacity-40"
>
{saving ? "저장 중..." : "저장"}
{saving ? "저장 중..." : "정리 없이 저장"}
</button>
</div>
{hasDraft && (
<div className="mt-6 pt-6 border-t border-[var(--color-border)] space-y-4">
<p className="text-sm font-semibold text-[var(--color-primary)]">
·
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1">
(PARA )
</label>
<input
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="예: Projects/사이트리뉴얼"
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
/>
</div>
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1"></label>
<input
value={project}
onChange={(e) => setProject(e.target.value)}
placeholder="없으면 비워두세요"
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1">
( )
</label>
<input
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="예: 예산안, next-action, 회의"
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
/>
</div>
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1"></label>
<textarea
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={3}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y text-sm"
/>
</div>
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1">
(Markdown)
</label>
<textarea
value={organizedContent}
onChange={(e) => setOrganizedContent(e.target.value)}
rows={12}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y font-mono text-sm"
/>
</div>
<button
onClick={handleSaveMemo}
disabled={saving || !category.trim()}
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40"
>
{saving ? "저장 중..." : "정리본 저장"}
</button>
</div>
)}
</div>
)}
</div>
</main>