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());
|
||||
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());
|
||||
result.put("id", id);
|
||||
item.put("id", id);
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
items.add(item);
|
||||
}
|
||||
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;
|
||||
}
|
||||
int last = t.lastIndexOf(close);
|
||||
if (last > start) {
|
||||
return t.substring(start, last + 1);
|
||||
}
|
||||
return t.substring(start);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,16 @@ import AuthGuard from "@/components/auth-guard";
|
||||
import NavBar from "@/components/nav-bar";
|
||||
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() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-8 text-center">Loading...</div>}>
|
||||
@@ -28,14 +38,9 @@ function NewNotePage() {
|
||||
const [transcription, setTranscription] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// AI 정리(PARA+GTD) 상태
|
||||
// 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 [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
|
||||
const handleSaveText = async () => {
|
||||
if (!title.trim() && !content.trim()) return;
|
||||
@@ -55,21 +60,26 @@ function NewNotePage() {
|
||||
if (!content.trim()) return;
|
||||
setOrganizing(true);
|
||||
try {
|
||||
const draft = await request<{
|
||||
const res = await request<{
|
||||
items: Array<{
|
||||
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);
|
||||
setDrafts(
|
||||
(res.items || []).map((it) => ({
|
||||
category: it.category || "",
|
||||
title: it.title || "",
|
||||
summary: it.summary || "",
|
||||
content: it.content || "",
|
||||
tags: (it.tags || []).join(", "),
|
||||
project: it.project || "",
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to organize memo:", err);
|
||||
alert("메모 정리에 실패했습니다.");
|
||||
@@ -78,22 +88,40 @@ function NewNotePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveMemo = async () => {
|
||||
if (!category.trim()) return;
|
||||
const updateDraft = (index: number, field: keyof Draft, value: string) => {
|
||||
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);
|
||||
try {
|
||||
const tags = tagsInput
|
||||
for (const d of valid) {
|
||||
const tags = d.tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const res = await request<{ id: string }>({
|
||||
await request({
|
||||
method: "POST",
|
||||
url: "/api/notes/memo",
|
||||
data: { category, title, summary, content: organizedContent, tags, project: project || null },
|
||||
data: {
|
||||
category: d.category,
|
||||
title: d.title,
|
||||
summary: d.summary,
|
||||
content: d.content,
|
||||
tags,
|
||||
project: d.project || null,
|
||||
},
|
||||
});
|
||||
router.push(`/notes/${res.id}`);
|
||||
}
|
||||
router.push("/notes");
|
||||
} catch (err) {
|
||||
console.error("Failed to save memo:", err);
|
||||
console.error("Failed to save memos:", err);
|
||||
alert("정리본 저장에 실패했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -225,19 +253,51 @@ function NewNotePage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasDraft && (
|
||||
{drafts.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--color-border)] space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<p className="text-sm font-semibold text-[var(--color-primary)]">
|
||||
✨ 정리 결과 — 확인·수정 후 저장하세요
|
||||
✨ 정리 결과 {drafts.length}건 — 확인·수정 후 저장하세요
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSaveAll}
|
||||
disabled={saving || drafts.every((d) => !d.category.trim())}
|
||||
className="px-5 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40 text-sm"
|
||||
>
|
||||
{saving ? "저장 중..." : `모두 저장 (${drafts.length}건)`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
@@ -245,8 +305,8 @@ function NewNotePage() {
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--color-text-muted)] mb-1">프로젝트</label>
|
||||
<input
|
||||
value={project}
|
||||
onChange={(e) => setProject(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
@@ -257,18 +317,18 @@ function NewNotePage() {
|
||||
태그 (쉼표로 구분)
|
||||
</label>
|
||||
<input
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder="예: 예산안, next-action, 회의"
|
||||
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={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
rows={3}
|
||||
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>
|
||||
@@ -277,19 +337,14 @@ function NewNotePage() {
|
||||
정리된 본문 (Markdown)
|
||||
</label>
|
||||
<textarea
|
||||
value={organizedContent}
|
||||
onChange={(e) => setOrganizedContent(e.target.value)}
|
||||
rows={12}
|
||||
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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user