memo: 한 입력의 여러 주제를 항목별로 분리 저장
- MemoService.organize() → List<MemoDraft> 반환. 프롬프트를 JSON 배열 출력으로
변경, 서로 다른 주제는 분리·관련 내용은 묶도록 지시. extractJson 배열 대응.
- POST /api/notes/organize → { items: [...] } (save=true면 전부 저장).
- 프론트: 정리 결과를 다중 카드로 표시, 항목별 수정·삭제 + '모두 저장'(카드마다 /memo).
실측: 두서없는 5주제 입력 → 5개 항목(각 PARA 카테고리+GTD 태그)으로 정확히 분리.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -97,20 +97,24 @@ public class NoteController {
|
||||
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);
|
||||
List<com.sundol.service.MemoService.MemoDraft> drafts = memoService.organize(userId, content);
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
for (com.sundol.service.MemoService.MemoDraft draft : drafts) {
|
||||
Map<String, Object> item = new java.util.LinkedHashMap<>();
|
||||
item.put("category", draft.category());
|
||||
item.put("title", draft.title());
|
||||
item.put("summary", draft.summary());
|
||||
item.put("content", draft.content());
|
||||
item.put("tags", draft.tags());
|
||||
item.put("project", draft.project());
|
||||
if (save) {
|
||||
String id = memoService.saveMemo(userId, draft.category(), draft.title(),
|
||||
draft.summary(), draft.content(), draft.tags(), draft.project());
|
||||
item.put("id", id);
|
||||
}
|
||||
items.add(item);
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
return ResponseEntity.ok(Map.<String, Object>of("items", items));
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
|
||||
@@ -41,32 +41,38 @@ public class MemoService {
|
||||
|
||||
private static final String ORGANIZE_SYSTEM = """
|
||||
당신은 PARA + GTD 기반 메모 정리·분류 전문가입니다.
|
||||
사용자가 두서없이 적은 메모를 읽고, 아래 JSON 객체 "하나만" 출력하세요. 다른 설명/코드펜스 없이 JSON만.
|
||||
사용자가 두서없이 적은 메모를 읽고, 아래 규칙에 따라 JSON "배열" 하나만 출력하세요. 다른 설명/코드펜스 없이 JSON만.
|
||||
|
||||
## 필드
|
||||
## 분리 규칙 (중요)
|
||||
- 하나의 입력에 서로 다른 주제·할일이 여러 개 섞여 있으면 각각 "별도 항목"으로 분리하세요.
|
||||
- 서로 관련된 내용(같은 주제·프로젝트)은 하나의 항목으로 묶으세요.
|
||||
- 결과는 항목 객체들의 JSON 배열입니다. 항목이 하나면 원소가 1개인 배열로 출력하세요.
|
||||
|
||||
## 각 항목(객체) 필드
|
||||
- category: 반드시 "Projects", "Areas", "Resources", "Archives" 중 하나로 시작하는 경로(예: "Projects/사이트리뉴얼").
|
||||
(Projects=마감·목표 있는 진행 과제, Areas=지속 책임영역, Resources=참고자료·관심사, Archives=완료·보관)
|
||||
가능하면 아래 "기존 카테고리" 중 적합한 것을 그대로 재사용하세요.
|
||||
- title: 12자 내외의 짧은 제목.
|
||||
- summary: 핵심 요약. 불릿 2~3줄(Markdown).
|
||||
- content: 구조화·교정된 본문(Markdown). 소제목·불릿으로 정리하되 원문의 정보를 빠짐없이 보존하세요.
|
||||
- summary: 핵심 요약. 불릿 1~3줄(Markdown).
|
||||
- content: 구조화·교정된 본문(Markdown). 소제목·불릿으로 정리하되 해당 항목의 정보를 빠짐없이 보존하세요.
|
||||
원문에 없는 내용을 추가하거나 추측하지 마세요. 정보를 임의로 삭제하지 마세요.
|
||||
- tags: 검색용 태그 배열. 실행이 필요한 메모면 GTD 액션 태그(next-action, waiting, someday, reference)를 함께 포함하세요.
|
||||
- tags: 검색용 태그 배열. 실행이 필요한 항목이면 GTD 액션 태그(next-action, waiting, someday, reference)를 함께 포함하세요.
|
||||
- project: 특정 프로젝트에 속하면 프로젝트명, 아니면 null.
|
||||
|
||||
## 규칙
|
||||
1. 원문과 같은 언어로 작성하세요.
|
||||
2. category는 위 4개 루트 중 하나로 반드시 시작해야 합니다.
|
||||
3. 반드시 유효한 JSON 하나만 출력하세요.
|
||||
2. 각 항목의 category는 위 4개 루트 중 하나로 반드시 시작해야 합니다.
|
||||
3. 반드시 유효한 JSON 배열 하나만 출력하세요.
|
||||
|
||||
## 기존 카테고리
|
||||
%s
|
||||
""";
|
||||
|
||||
/**
|
||||
* 메모를 LLM으로 정리·분류하여 구조화된 초안을 반환한다. (DB 저장 안 함)
|
||||
* 메모를 LLM으로 정리·분류하여 구조화된 초안 목록을 반환한다. (DB 저장 안 함)
|
||||
* 입력에 서로 다른 주제가 여러 개면 항목별로 분리되어 여러 초안이 반환된다.
|
||||
*/
|
||||
public MemoDraft organize(String userId, String rawText) throws Exception {
|
||||
public List<MemoDraft> organize(String userId, String rawText) throws Exception {
|
||||
if (rawText == null || rawText.isBlank()) {
|
||||
throw new IllegalArgumentException("정리할 메모 내용이 비어 있습니다.");
|
||||
}
|
||||
@@ -85,17 +91,33 @@ public class MemoService {
|
||||
String llm = genAiService.chat(systemMsg, "아래 메모를 정리·분류하세요:\n\n" + rawText, null);
|
||||
|
||||
String json = extractJson(llm);
|
||||
JsonNode n;
|
||||
JsonNode root;
|
||||
try {
|
||||
n = objectMapper.readTree(json);
|
||||
root = objectMapper.readTree(json);
|
||||
} catch (Exception e) {
|
||||
log.error("메모 정리 JSON 파싱 실패. 원문 응답: {}", llm.substring(0, Math.min(500, llm.length())));
|
||||
throw new IllegalStateException("LLM 응답을 JSON으로 파싱하지 못했습니다: " + e.getMessage());
|
||||
}
|
||||
|
||||
List<MemoDraft> drafts = new ArrayList<>();
|
||||
if (root.isArray()) {
|
||||
for (JsonNode n : root) drafts.add(parseDraft(n));
|
||||
} else if (root.isObject()) {
|
||||
drafts.add(parseDraft(root));
|
||||
}
|
||||
if (drafts.isEmpty()) {
|
||||
throw new IllegalStateException("LLM이 정리 항목을 반환하지 않았습니다.");
|
||||
}
|
||||
return drafts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 JSON 항목을 MemoDraft로 파싱한다.
|
||||
*/
|
||||
private MemoDraft parseDraft(JsonNode n) {
|
||||
String category = n.path("category").asText("").strip();
|
||||
if (category.isBlank()) {
|
||||
throw new IllegalStateException("LLM이 category를 반환하지 않았습니다.");
|
||||
throw new IllegalStateException("LLM 항목에 category가 없습니다.");
|
||||
}
|
||||
|
||||
List<String> tags = new ArrayList<>();
|
||||
@@ -152,7 +174,8 @@ public class MemoService {
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 응답에서 JSON 객체 부분만 추출한다. 코드펜스(```)와 앞뒤 잡텍스트를 제거한다.
|
||||
* LLM 응답에서 JSON 부분만 추출한다. 코드펜스(```)와 앞뒤 잡텍스트를 제거한다.
|
||||
* 배열([...])이 먼저 나오면 배열을, 객체({...})가 먼저 나오면 객체를 추출한다.
|
||||
*/
|
||||
private String extractJson(String s) {
|
||||
String t = s.strip();
|
||||
@@ -162,11 +185,23 @@ public class MemoService {
|
||||
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);
|
||||
int firstArr = t.indexOf('[');
|
||||
int firstObj = t.indexOf('{');
|
||||
char close;
|
||||
int start;
|
||||
if (firstArr >= 0 && (firstObj < 0 || firstArr < firstObj)) {
|
||||
start = firstArr;
|
||||
close = ']';
|
||||
} else if (firstObj >= 0) {
|
||||
start = firstObj;
|
||||
close = '}';
|
||||
} else {
|
||||
return t;
|
||||
}
|
||||
return t;
|
||||
int last = t.lastIndexOf(close);
|
||||
if (last > start) {
|
||||
return t.substring(start, last + 1);
|
||||
}
|
||||
return t.substring(start);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user