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:
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user