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:
2026-07-01 06:41:58 +00:00
parent df9c6a6b50
commit b7588fa3b6
3 changed files with 222 additions and 128 deletions

View File

@@ -97,20 +97,24 @@ public class NoteController {
String content = str(body.get("content")); String content = str(body.get("content"));
boolean save = Boolean.TRUE.equals(body.get("save")) boolean save = Boolean.TRUE.equals(body.get("save"))
|| "true".equalsIgnoreCase(String.valueOf(body.get("save"))); || "true".equalsIgnoreCase(String.valueOf(body.get("save")));
com.sundol.service.MemoService.MemoDraft draft = memoService.organize(userId, content); List<com.sundol.service.MemoService.MemoDraft> drafts = memoService.organize(userId, content);
Map<String, Object> result = new java.util.LinkedHashMap<>(); List<Map<String, Object>> items = new ArrayList<>();
result.put("category", draft.category()); for (com.sundol.service.MemoService.MemoDraft draft : drafts) {
result.put("title", draft.title()); Map<String, Object> item = new java.util.LinkedHashMap<>();
result.put("summary", draft.summary()); item.put("category", draft.category());
result.put("content", draft.content()); item.put("title", draft.title());
result.put("tags", draft.tags()); item.put("summary", draft.summary());
result.put("project", draft.project()); item.put("content", draft.content());
if (save) { item.put("tags", draft.tags());
String id = memoService.saveMemo(userId, draft.category(), draft.title(), item.put("project", draft.project());
draft.summary(), draft.content(), draft.tags(), draft.project()); if (save) {
result.put("id", id); 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()); }).subscribeOn(Schedulers.boundedElastic());
} }

View File

@@ -41,32 +41,38 @@ public class MemoService {
private static final String ORGANIZE_SYSTEM = """ private static final String ORGANIZE_SYSTEM = """
당신은 PARA + GTD 기반 메모 정리·분류 전문가입니다. 당신은 PARA + GTD 기반 메모 정리·분류 전문가입니다.
사용자가 두서없이 적은 메모를 읽고, 아래 JSON 객체 "하나만" 출력하세요. 다른 설명/코드펜스 없이 JSON만. 사용자가 두서없이 적은 메모를 읽고, 아래 규칙에 따라 JSON "배열" 하나만 출력하세요. 다른 설명/코드펜스 없이 JSON만.
## 필드 ## 분리 규칙 (중요)
- 하나의 입력에 서로 다른 주제·할일이 여러 개 섞여 있으면 각각 "별도 항목"으로 분리하세요.
- 서로 관련된 내용(같은 주제·프로젝트)은 하나의 항목으로 묶으세요.
- 결과는 항목 객체들의 JSON 배열입니다. 항목이 하나면 원소가 1개인 배열로 출력하세요.
## 각 항목(객체) 필드
- category: 반드시 "Projects", "Areas", "Resources", "Archives" 중 하나로 시작하는 경로(예: "Projects/사이트리뉴얼"). - category: 반드시 "Projects", "Areas", "Resources", "Archives" 중 하나로 시작하는 경로(예: "Projects/사이트리뉴얼").
(Projects=마감·목표 있는 진행 과제, Areas=지속 책임영역, Resources=참고자료·관심사, Archives=완료·보관) (Projects=마감·목표 있는 진행 과제, Areas=지속 책임영역, Resources=참고자료·관심사, Archives=완료·보관)
가능하면 아래 "기존 카테고리" 중 적합한 것을 그대로 재사용하세요. 가능하면 아래 "기존 카테고리" 중 적합한 것을 그대로 재사용하세요.
- title: 12자 내외의 짧은 제목. - title: 12자 내외의 짧은 제목.
- summary: 핵심 요약. 불릿 2~3줄(Markdown). - summary: 핵심 요약. 불릿 1~3줄(Markdown).
- content: 구조화·교정된 본문(Markdown). 소제목·불릿으로 정리하되 원문의 정보를 빠짐없이 보존하세요. - content: 구조화·교정된 본문(Markdown). 소제목·불릿으로 정리하되 해당 항목의 정보를 빠짐없이 보존하세요.
원문에 없는 내용을 추가하거나 추측하지 마세요. 정보를 임의로 삭제하지 마세요. 원문에 없는 내용을 추가하거나 추측하지 마세요. 정보를 임의로 삭제하지 마세요.
- tags: 검색용 태그 배열. 실행이 필요한 메모면 GTD 액션 태그(next-action, waiting, someday, reference)를 함께 포함하세요. - tags: 검색용 태그 배열. 실행이 필요한 항목이면 GTD 액션 태그(next-action, waiting, someday, reference)를 함께 포함하세요.
- project: 특정 프로젝트에 속하면 프로젝트명, 아니면 null. - project: 특정 프로젝트에 속하면 프로젝트명, 아니면 null.
## 규칙 ## 규칙
1. 원문과 같은 언어로 작성하세요. 1. 원문과 같은 언어로 작성하세요.
2. category는 위 4개 루트 중 하나로 반드시 시작해야 합니다. 2. 각 항목의 category는 위 4개 루트 중 하나로 반드시 시작해야 합니다.
3. 반드시 유효한 JSON 하나만 출력하세요. 3. 반드시 유효한 JSON 배열 하나만 출력하세요.
## 기존 카테고리 ## 기존 카테고리
%s %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()) { if (rawText == null || rawText.isBlank()) {
throw new IllegalArgumentException("정리할 메모 내용이 비어 있습니다."); throw new IllegalArgumentException("정리할 메모 내용이 비어 있습니다.");
} }
@@ -85,17 +91,33 @@ public class MemoService {
String llm = genAiService.chat(systemMsg, "아래 메모를 정리·분류하세요:\n\n" + rawText, null); String llm = genAiService.chat(systemMsg, "아래 메모를 정리·분류하세요:\n\n" + rawText, null);
String json = extractJson(llm); String json = extractJson(llm);
JsonNode n; JsonNode root;
try { try {
n = objectMapper.readTree(json); root = objectMapper.readTree(json);
} catch (Exception e) { } catch (Exception e) {
log.error("메모 정리 JSON 파싱 실패. 원문 응답: {}", llm.substring(0, Math.min(500, llm.length()))); log.error("메모 정리 JSON 파싱 실패. 원문 응답: {}", llm.substring(0, Math.min(500, llm.length())));
throw new IllegalStateException("LLM 응답을 JSON으로 파싱하지 못했습니다: " + e.getMessage()); 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(); String category = n.path("category").asText("").strip();
if (category.isBlank()) { if (category.isBlank()) {
throw new IllegalStateException("LLM category를 반환하지 않았습니다."); throw new IllegalStateException("LLM 항목에 category가 없습니다.");
} }
List<String> tags = new ArrayList<>(); List<String> tags = new ArrayList<>();
@@ -152,7 +174,8 @@ public class MemoService {
} }
/** /**
* LLM 응답에서 JSON 객체 부분만 추출한다. 코드펜스(```)와 앞뒤 잡텍스트를 제거한다. * LLM 응답에서 JSON 부분만 추출한다. 코드펜스(```)와 앞뒤 잡텍스트를 제거한다.
* 배열([...])이 먼저 나오면 배열을, 객체({...})가 먼저 나오면 객체를 추출한다.
*/ */
private String extractJson(String s) { private String extractJson(String s) {
String t = s.strip(); String t = s.strip();
@@ -162,11 +185,23 @@ public class MemoService {
if (t.endsWith("```")) t = t.substring(0, t.length() - 3); if (t.endsWith("```")) t = t.substring(0, t.length() - 3);
t = t.strip(); t = t.strip();
} }
int a = t.indexOf('{'); int firstArr = t.indexOf('[');
int b = t.lastIndexOf('}'); int firstObj = t.indexOf('{');
if (a >= 0 && b > a) { char close;
return t.substring(a, b + 1); 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);
} }
} }

View File

@@ -6,6 +6,16 @@ import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar"; import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api"; import { useApi } from "@/lib/use-api";
// 정리 결과 카드 1개. 한 입력이 여러 항목으로 분리될 수 있다. tags는 편집 편의상 쉼표 문자열로 보관.
type Draft = {
category: string;
title: string;
summary: string;
content: string;
tags: string;
project: string;
};
export default function NewNotePageWrapper() { export default function NewNotePageWrapper() {
return ( return (
<Suspense fallback={<div className="p-8 text-center">Loading...</div>}> <Suspense fallback={<div className="p-8 text-center">Loading...</div>}>
@@ -28,14 +38,9 @@ function NewNotePage() {
const [transcription, setTranscription] = useState(""); const [transcription, setTranscription] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// AI 정리(PARA+GTD) 상태 // AI 정리(PARA+GTD) 상태 — 한 입력이 여러 항목으로 분리될 수 있어 배열로 관리
const [organizing, setOrganizing] = useState(false); const [organizing, setOrganizing] = useState(false);
const [hasDraft, setHasDraft] = useState(false); const [drafts, setDrafts] = useState<Draft[]>([]);
const [category, setCategory] = useState("");
const [project, setProject] = useState("");
const [tagsInput, setTagsInput] = useState("");
const [summary, setSummary] = useState("");
const [organizedContent, setOrganizedContent] = useState("");
const handleSaveText = async () => { const handleSaveText = async () => {
if (!title.trim() && !content.trim()) return; if (!title.trim() && !content.trim()) return;
@@ -55,21 +60,26 @@ function NewNotePage() {
if (!content.trim()) return; if (!content.trim()) return;
setOrganizing(true); setOrganizing(true);
try { try {
const draft = await request<{ const res = await request<{
category: string; items: Array<{
title: string; category: string;
summary: string; title: string;
content: string; summary: string;
tags: string[]; content: string;
project: string | null; tags: string[];
project: string | null;
}>;
}>({ method: "POST", url: "/api/notes/organize", data: { content } }); }>({ method: "POST", url: "/api/notes/organize", data: { content } });
setTitle(draft.title || ""); setDrafts(
setCategory(draft.category || ""); (res.items || []).map((it) => ({
setProject(draft.project || ""); category: it.category || "",
setTagsInput((draft.tags || []).join(", ")); title: it.title || "",
setSummary(draft.summary || ""); summary: it.summary || "",
setOrganizedContent(draft.content || ""); content: it.content || "",
setHasDraft(true); tags: (it.tags || []).join(", "),
project: it.project || "",
}))
);
} catch (err) { } catch (err) {
console.error("Failed to organize memo:", err); console.error("Failed to organize memo:", err);
alert("메모 정리에 실패했습니다."); alert("메모 정리에 실패했습니다.");
@@ -78,22 +88,40 @@ function NewNotePage() {
} }
}; };
const handleSaveMemo = async () => { const updateDraft = (index: number, field: keyof Draft, value: string) => {
if (!category.trim()) return; setDrafts((prev) => prev.map((d, i) => (i === index ? { ...d, [field]: value } : d)));
};
const removeDraft = (index: number) => {
setDrafts((prev) => prev.filter((_, i) => i !== index));
};
const handleSaveAll = async () => {
const valid = drafts.filter((d) => d.category.trim());
if (valid.length === 0) return;
setSaving(true); setSaving(true);
try { try {
const tags = tagsInput for (const d of valid) {
.split(",") const tags = d.tags
.map((t) => t.trim()) .split(",")
.filter(Boolean); .map((t) => t.trim())
const res = await request<{ id: string }>({ .filter(Boolean);
method: "POST", await request({
url: "/api/notes/memo", method: "POST",
data: { category, title, summary, content: organizedContent, tags, project: project || null }, url: "/api/notes/memo",
}); data: {
router.push(`/notes/${res.id}`); category: d.category,
title: d.title,
summary: d.summary,
content: d.content,
tags,
project: d.project || null,
},
});
}
router.push("/notes");
} catch (err) { } catch (err) {
console.error("Failed to save memo:", err); console.error("Failed to save memos:", err);
alert("정리본 저장에 실패했습니다."); alert("정리본 저장에 실패했습니다.");
} finally { } finally {
setSaving(false); setSaving(false);
@@ -225,71 +253,98 @@ function NewNotePage() {
</button> </button>
</div> </div>
{hasDraft && ( {drafts.length > 0 && (
<div className="mt-6 pt-6 border-t border-[var(--color-border)] space-y-4"> <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)]"> <div className="flex items-center justify-between flex-wrap gap-2">
· <p className="text-sm font-semibold text-[var(--color-primary)]">
</p> {drafts.length} ·
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> </p>
<div> <button
<label className="block text-xs text-[var(--color-text-muted)] mb-1"> onClick={handleSaveAll}
(PARA ) disabled={saving || drafts.every((d) => !d.category.trim())}
</label> className="px-5 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40 text-sm"
<input >
value={category} {saving ? "저장 중..." : `모두 저장 (${drafts.length}건)`}
onChange={(e) => setCategory(e.target.value)} </button>
placeholder="예: Projects/사이트리뉴얼" </div>
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"
/> {drafts.map((d, i) => (
<div
key={i}
className="rounded-xl border border-[var(--color-border)] p-4 space-y-3"
>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--color-text-muted)]"> {i + 1}</span>
<button
onClick={() => removeDraft(i)}
className="text-xs text-[var(--color-text-muted)] hover:text-red-400"
>
</button>
</div>
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1"></label>
<input
value={d.title}
onChange={(e) => updateDraft(i, "title", e.target.value)}
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 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={d.category}
onChange={(e) => updateDraft(i, "category", 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={d.project}
onChange={(e) => updateDraft(i, "project", 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={d.tags}
onChange={(e) => updateDraft(i, "tags", 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={d.summary}
onChange={(e) => updateDraft(i, "summary", e.target.value)}
rows={2}
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={d.content}
onChange={(e) => updateDraft(i, "content", e.target.value)}
rows={8}
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>
</div> </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> </div>