From 93d24faad4bede34547efb36b65edf412190d9e8 Mon Sep 17 00:00:00 2001 From: Jacob Date: Wed, 2 Sep 2026 23:19:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A7=80=EC=8B=9D=EB=B2=A0=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=AC=B8=EC=84=9C=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?/=EC=9D=B8=EC=A6=9D=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지식베이스(RAG) 문서 삭제 API(DELETE /documents/{source}) 및 UI 연동 - emb_passages 단락 및 data/raw_documents 원본 파일 정리 지원 - 채팅 세션 404 시 자동 정리 및 미사용 TTS 액션 정리 - 인증 확인 중 가이드 투어 노출 깜빡임 방지 --- backend/app/api/graph.py | 17 +++++ frontend/src/app/chat/KnowledgePanel.tsx | 35 ++++++++- frontend/src/app/chat/page.tsx | 89 ++++------------------ frontend/src/lib/api.ts | 4 + frontend/src/lib/user-context.tsx | 17 ++++- shared/database/repositories/embeddings.py | 12 +++ 6 files changed, 95 insertions(+), 79 deletions(-) diff --git a/backend/app/api/graph.py b/backend/app/api/graph.py index 4d02080..18fdfae 100644 --- a/backend/app/api/graph.py +++ b/backend/app/api/graph.py @@ -17,6 +17,7 @@ from backend.app.services.jobs import PROJECT_ROOT, job_manager from shared.database.connector import list_emb_sources from shared.database.neo4j_client import fetch_graph_snapshot +from shared.database.repositories.embeddings import delete_emb_passages_by_source logger = logging.getLogger(__name__) @@ -40,6 +41,22 @@ def list_documents() -> dict: return {"documents": list_emb_sources()} +@router.delete("/documents/{source}") +def delete_document(source: str) -> dict: + """지식베이스에서 문서 하나를 지운다 — emb_passages 단락 + data/raw_documents/ 원본 파일. + + 이미 그래프에 반영된 엔티티/관계는 지우지 않는다(어느 노드가 이 source에서 나왔는지 + 추적 안 함). 필요하면 지식그래프 재빌드로 정리한다. + """ + deleted = delete_emb_passages_by_source(source) + if deleted == 0: + raise HTTPException(status_code=404, detail=f"반영된 문서를 찾을 수 없습니다: {source}") + raw_path = RAW_DIR / Path(source).name + if raw_path.exists(): + raw_path.unlink() + return {"source": source, "deleted_passages": deleted} + + @router.post("/upload") async def upload_document(file: UploadFile = File(...)) -> dict: """금융 문서를 data/raw_documents/에 저장한다 (챗봇 지식베이스 패널의 파일 첨부용).""" diff --git a/frontend/src/app/chat/KnowledgePanel.tsx b/frontend/src/app/chat/KnowledgePanel.tsx index 06169dd..7730fd6 100644 --- a/frontend/src/app/chat/KnowledgePanel.tsx +++ b/frontend/src/app/chat/KnowledgePanel.tsx @@ -2,10 +2,11 @@ import { useCallback, useEffect, useState } from "react"; import { errMsg } from "@/lib/async"; -import { FileText, UploadSimple, Sparkle, ArrowsClockwise } from "@phosphor-icons/react"; +import { FileText, TrashSimple, UploadSimple, Sparkle, ArrowsClockwise } from "@phosphor-icons/react"; import { apiUpload, buildGraph, + deleteRagDocument, ingestDocument, listRagDocuments, type JobState, @@ -23,6 +24,7 @@ export default function KnowledgePanel() { const [uploading, setUploading] = useState(false); const [ingestJobId, setIngestJobId] = useState(null); const [buildJobId, setBuildJobId] = useState(null); + const [deletingSource, setDeletingSource] = useState(null); const loadDocs = useCallback(async () => { try { @@ -56,6 +58,20 @@ export default function KnowledgePanel() { } }; + const removeDoc = async (source: string) => { + if (!confirm(`"${source}"를 지식베이스에서 삭제할까요? 이미 임베딩된 단락이 모두 지워집니다.`)) return; + setDeletingSource(source); + try { + await deleteRagDocument(source); + toast(`"${source}"를 삭제했습니다.`, "success"); + await loadDocs(); + } catch (e) { + toast(`삭제 실패: ${errMsg(e)}`, "error"); + } finally { + setDeletingSource(null); + } + }; + const onIngestDone = (job: JobState) => { if (job.status === "failed") { toast("임베딩이 실패했습니다. 로그를 확인하세요.", "error"); @@ -102,15 +118,28 @@ export default function KnowledgePanel() { {docs.map((d) => (
  • -
    +
    {d.source}
    {d.passages}개 단락
    +
  • ))} diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index 36ab4b4..b30ba6c 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -7,8 +7,6 @@ import { Plus, TrashSimple, PaperPlaneTilt, - SpeakerHigh, - Stop, SidebarSimple, X, ShieldCheck, @@ -37,35 +35,11 @@ interface Msg { content: string; } -// ── 읽어주기(TTS) ── Web Speech API(speechSynthesis)는 브라우저 전역 단일 채널. -// 소유 판정은 각 답변의 로컬 ref로 한다 — cancel()이 남의 재생을 끊으면 그 인스턴스만 -// onend를 받아 자기 버튼을 원래대로 되돌린다. - // 어시스턴트 답변 렌더: 본문 마크다운 + 실시간 5겹 방어 검증 증명서 + 출처 칩 리스트. function AssistantAnswer({ content }: { content: string }) { const { body, defense, sources } = splitChatSources(content); - const [speaking, setSpeaking] = useState(false); const [copied, setCopied] = useState(false); const toast = useToast(); - /** 이벤트 핸들러(onend)가 언마운트 후 상태를 건드리지 않게 실제 재생 소유를 ref로 이중화 */ - const speakingRef = useRef(false); - - useEffect(() => { - // 언마운트(대화 전환 등) 시 내가 읽는 중이었을 때만 끊는다 — 남의 재생을 건드리지 않음. - return () => { - if (speakingRef.current && typeof speechSynthesis !== "undefined") { - speechSynthesis.cancel(); - speakingRef.current = false; - } - }; - }, []); - - const finishSpeak = () => { - if (speakingRef.current) { - speakingRef.current = false; - setSpeaking(false); - } - }; const copyAnswer = async () => { try { @@ -78,31 +52,6 @@ function AssistantAnswer({ content }: { content: string }) { } }; - const toggleSpeak = () => { - if (typeof speechSynthesis === "undefined") return; - const synth = speechSynthesis; - if (speaking) { - speakingRef.current = false; - setSpeaking(false); - synth.cancel(); - return; - } - const fenceAt = body.search(/^---\s*$/m); - const text = (fenceAt === -1 ? body : body.slice(0, fenceAt)) - .replace(/\[\d+\]/g, "") - .trim(); - if (!text) return; - synth.cancel(); - const utterance = new SpeechSynthesisUtterance(text); - utterance.lang = "ko-KR"; - utterance.rate = 0.95; - utterance.onend = finishSpeak; - utterance.onerror = finishSpeak; - speakingRef.current = true; - setSpeaking(true); - synth.speak(utterance); - }; - return ( <> {body} @@ -169,28 +118,8 @@ function AssistantAnswer({ content }: { content: string }) { )} - {/* ── 액션 툴바: 읽어주기 + 답변 복사 ── */} + {/* ── 액션 툴바: 답변 복사 ── */}
    -