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

@@ -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,17 +204,94 @@ 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"
/>
<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"
>
{saving ? "저장 중..." : "저장"}
</button>
<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="px-6 py-2 rounded-lg border border-[var(--color-border)] hover:bg-[var(--color-bg-hover)] transition-colors disabled:opacity-40"
>
{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>