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 f6e1137..11313c7 100644 --- a/sundol-backend/src/main/java/com/sundol/controller/NoteController.java +++ b/sundol-backend/src/main/java/com/sundol/controller/NoteController.java @@ -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 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 drafts = memoService.organize(userId, content); + List> items = new ArrayList<>(); + for (com.sundol.service.MemoService.MemoDraft draft : drafts) { + Map 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.of("items", items)); }).subscribeOn(Schedulers.boundedElastic()); } diff --git a/sundol-backend/src/main/java/com/sundol/service/MemoService.java b/sundol-backend/src/main/java/com/sundol/service/MemoService.java index 1cfc866..64b2e1a 100644 --- a/sundol-backend/src/main/java/com/sundol/service/MemoService.java +++ b/sundol-backend/src/main/java/com/sundol/service/MemoService.java @@ -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 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 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 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); } } diff --git a/sundol-frontend/src/app/notes/new/page.tsx b/sundol-frontend/src/app/notes/new/page.tsx index 6887a36..93f31ed 100644 --- a/sundol-frontend/src/app/notes/new/page.tsx +++ b/sundol-frontend/src/app/notes/new/page.tsx @@ -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 ( Loading...}> @@ -28,14 +38,9 @@ function NewNotePage() { const [transcription, setTranscription] = useState(""); const fileInputRef = useRef(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([]); 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<{ - category: string; - title: string; - summary: string; - content: string; - tags: string[]; - project: string | null; + 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 - .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}`); + for (const d of valid) { + const tags = d.tags + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + await request({ + method: "POST", + url: "/api/notes/memo", + data: { + category: d.category, + title: d.title, + summary: d.summary, + content: d.content, + tags, + project: d.project || null, + }, + }); + } + router.push("/notes"); } catch (err) { - console.error("Failed to save memo:", err); + console.error("Failed to save memos:", err); alert("정리본 저장에 실패했습니다."); } finally { setSaving(false); @@ -225,71 +253,98 @@ function NewNotePage() { - {hasDraft && ( + {drafts.length > 0 && (
-

- ✨ 정리 결과 — 확인·수정 후 저장하세요 -

-
-
- - 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" - /> +
+

+ ✨ 정리 결과 {drafts.length}건 — 확인·수정 후 저장하세요 +

+ +
+ + {drafts.map((d, i) => ( +
+
+ 항목 {i + 1} + +
+
+ + 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" + /> +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ + 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" + /> +
+
+ +