Add knowledge structuring feature with incremental LLM processing

- Add structured_content column and STRUCTURING pipeline step
- Split LLM structuring into TOC + per-section calls to avoid token limit
- Save intermediate results to DB for real-time frontend polling (3s)
- Add manual "정리하기" button with async processing
- Fix browser login modal by customizing authentication entry point
- Fix standalone build symlinks for server.js and static files
- Add troubleshooting guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 19:43:21 +00:00
parent afc9cdcde6
commit 9abb770e37
8 changed files with 547 additions and 31 deletions

View File

@@ -25,16 +25,33 @@ echo "=== [1/3] Next.js 빌드 ==="
npx next build
echo "=== [2/3] 심볼릭 링크 생성 ==="
STATIC_SRC="$SCRIPT_DIR/.next/static"
STATIC_DST="$SCRIPT_DIR/.next/standalone/.next/static"
STANDALONE_DIR="$SCRIPT_DIR/.next/standalone"
# static 링크
STATIC_SRC="$SCRIPT_DIR/.next/static"
STATIC_DST="$STANDALONE_DIR/.next/static"
mkdir -p "$STANDALONE_DIR/.next"
if [ -L "$STATIC_DST" ] || [ -e "$STATIC_DST" ]; then
rm -rf "$STATIC_DST"
fi
ln -s "$STATIC_SRC" "$STATIC_DST"
echo "링크 생성 완료: $STATIC_DST -> $STATIC_SRC"
# standalone output이 subdirectory에 생성되는 경우 대응
if [ -d "$STANDALONE_DIR/sundol-frontend" ]; then
# server.js 링크
if [ ! -f "$STANDALONE_DIR/server.js" ] && [ -f "$STANDALONE_DIR/sundol-frontend/server.js" ]; then
ln -sf "$STANDALONE_DIR/sundol-frontend/server.js" "$STANDALONE_DIR/server.js"
echo "server.js 링크 생성 완료"
fi
# nested static 링크
NESTED_STATIC="$STANDALONE_DIR/sundol-frontend/.next/static"
if [ ! -L "$NESTED_STATIC" ] && [ ! -e "$NESTED_STATIC" ]; then
ln -s "$STATIC_SRC" "$NESTED_STATIC"
echo "nested static 링크 생성 완료"
fi
fi
echo "=== [3/3] PM2 재시작 ==="
pm2 restart sundol-frontend

View File

@@ -19,6 +19,7 @@ interface KnowledgeItem {
TITLE: string;
SOURCE_URL: string;
RAW_TEXT: string;
STRUCTURED_CONTENT: string | null;
STATUS: string;
CREATED_AT: string;
UPDATED_AT: string;
@@ -37,6 +38,7 @@ const statusColors: Record<string, string> = {
EXTRACTING: "bg-blue-500/20 text-blue-400",
CHUNKING: "bg-purple-500/20 text-purple-400",
CATEGORIZING: "bg-indigo-500/20 text-indigo-400",
STRUCTURING: "bg-orange-500/20 text-orange-400",
EMBEDDING: "bg-cyan-500/20 text-cyan-400",
READY: "bg-green-500/20 text-green-400",
FAILED: "bg-red-500/20 text-red-400",
@@ -74,6 +76,8 @@ export default function KnowledgeDetailPage() {
const [showChunks, setShowChunks] = useState(false);
const [deleting, setDeleting] = useState(false);
const [generating, setGenerating] = useState(false);
const [structuring, setStructuring] = useState(false);
const [showStructured, setShowStructured] = useState(true);
const fetchItem = async () => {
try {
@@ -103,7 +107,7 @@ export default function KnowledgeDetailPage() {
// Poll while processing
useEffect(() => {
if (!item) return;
const processing = ["PENDING", "EXTRACTING", "CHUNKING", "CATEGORIZING", "EMBEDDING"].includes(item.STATUS);
const processing = ["PENDING", "EXTRACTING", "STRUCTURING", "CHUNKING", "CATEGORIZING", "EMBEDDING"].includes(item.STATUS);
if (!processing) return;
const interval = setInterval(fetchItem, 3000);
return () => clearInterval(interval);
@@ -278,12 +282,13 @@ export default function KnowledgeDetailPage() {
</div>
)}
{["PENDING", "EXTRACTING", "CHUNKING", "CATEGORIZING"].includes(item.STATUS) && (
{["PENDING", "EXTRACTING", "STRUCTURING", "CHUNKING", "CATEGORIZING", "EMBEDDING"].includes(item.STATUS) && (
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-4 mb-6 flex items-center gap-3">
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-blue-400">
{item.STATUS === "PENDING" && "파이프라인 대기 중..."}
{item.STATUS === "EXTRACTING" && "텍스트 추출 중..."}
{item.STATUS === "STRUCTURING" && "내용 정리 중..."}
{item.STATUS === "CHUNKING" && "청킹 처리 중..."}
{item.STATUS === "CATEGORIZING" && "카테고리 분류 중..."}
{item.STATUS === "EMBEDDING" && "벡터 임베딩 중..."}
@@ -291,6 +296,34 @@ export default function KnowledgeDetailPage() {
</div>
)}
{/* Structured Content */}
{item.STRUCTURED_CONTENT && (
<div className="mb-6">
<button
onClick={() => setShowStructured(!showStructured)}
className="text-sm text-[var(--color-primary)] hover:underline mb-3"
>
{showStructured ? "▼ 정리된 내용 숨기기" : "▶ 정리된 내용 보기"}
</button>
{showStructured && (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] prose prose-invert max-w-none">
<div
className="text-sm leading-relaxed whitespace-pre-wrap"
dangerouslySetInnerHTML={{
__html: item.STRUCTURED_CONTENT
.replace(/^### (.+)$/gm, '<h3 class="text-base font-bold mt-4 mb-2 text-[var(--color-text)]">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 class="text-lg font-bold mt-5 mb-2 text-[var(--color-text)]">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 class="text-xl font-bold mt-6 mb-3 text-[var(--color-text)]">$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>')
.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 list-decimal">$2</li>')
}}
/>
</div>
)}
</div>
)}
{/* Chunks toggle */}
{item.STATUS === "READY" && (
<div className="mb-6">
@@ -331,27 +364,56 @@ export default function KnowledgeDetailPage() {
{/* Actions */}
<div className="pt-4 border-t border-[var(--color-border)] flex items-center gap-4">
{item.STATUS === "READY" && (
<button
onClick={async () => {
setGenerating(true);
try {
const result = await request<{ generated: number }>({
method: "POST",
url: `/api/study-cards/generate/${id}`,
});
alert(`${result.generated}개의 스터디 카드가 생성되었습니다.`);
} catch (err) {
console.error("Failed to generate cards:", err);
alert("카드 생성에 실패했습니다.");
} finally {
setGenerating(false);
}
}}
disabled={generating}
className="text-sm text-[var(--color-primary)] hover:underline disabled:opacity-40"
>
{generating ? "카드 생성 중..." : "스터디 카드 생성"}
</button>
<>
<button
onClick={async () => {
setStructuring(true);
setShowStructured(true);
try {
// 비동기 요청: 서버가 STRUCTURING 상태로 변경 후 백그라운드 처리
// 폴링이 자동으로 진행 상태를 갱신함
await request({
method: "POST",
url: `/api/knowledge/${id}/structure`,
data: {},
});
// 완료 후 최종 상태 갱신
await fetchItem();
} catch (err) {
console.error("Failed to structure:", err);
alert("내용 정리에 실패했습니다.");
await fetchItem();
} finally {
setStructuring(false);
}
}}
disabled={structuring}
className="text-sm text-[var(--color-primary)] hover:underline disabled:opacity-40"
>
{structuring ? "정리 중..." : item.STRUCTURED_CONTENT ? "다시 정리하기" : "내용 정리하기"}
</button>
<button
onClick={async () => {
setGenerating(true);
try {
const result = await request<{ generated: number }>({
method: "POST",
url: `/api/study-cards/generate/${id}`,
});
alert(`${result.generated}개의 스터디 카드가 생성되었습니다.`);
} catch (err) {
console.error("Failed to generate cards:", err);
alert("카드 생성에 실패했습니다.");
} finally {
setGenerating(false);
}
}}
disabled={generating}
className="text-sm text-[var(--color-primary)] hover:underline disabled:opacity-40"
>
{generating ? "카드 생성 중..." : "스터디 카드 생성"}
</button>
</>
)}
<button
onClick={handleDelete}