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 }) { )} - {/* ── 액션 툴바: 읽어주기 + 답변 복사 ── */} + {/* ── 액션 툴바: 답변 복사 ── */}
    -