Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions backend/app/api/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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/에 저장한다 (챗봇 지식베이스 패널의 파일 첨부용)."""
Expand Down
35 changes: 32 additions & 3 deletions frontend/src/app/chat/KnowledgePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +24,7 @@ export default function KnowledgePanel() {
const [uploading, setUploading] = useState(false);
const [ingestJobId, setIngestJobId] = useState<string | null>(null);
const [buildJobId, setBuildJobId] = useState<string | null>(null);
const [deletingSource, setDeletingSource] = useState<string | null>(null);

const loadDocs = useCallback(async () => {
try {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -102,15 +118,28 @@ export default function KnowledgePanel() {
{docs.map((d) => (
<li
key={d.source}
className="flex items-start gap-1.5 rounded-lg bg-[color-mix(in_srgb,var(--accent)_5%,transparent)] px-2 py-1.5"
className="group flex items-start gap-1.5 rounded-lg bg-[color-mix(in_srgb,var(--accent)_5%,transparent)] px-2 py-1.5"
>
<FileText size={14} className="mt-0.5 shrink-0 text-accent" />
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-fg" title={d.source}>
{d.source}
</div>
<div className="text-[10px] text-muted">{d.passages}개 단락</div>
</div>
<button
onClick={() => removeDoc(d.source)}
disabled={deletingSource === d.source}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted opacity-0 transition hover:text-negative group-hover:opacity-100 disabled:opacity-40"
aria-label={`${d.source} 삭제`}
title="지식베이스에서 삭제"
>
{deletingSource === d.source ? (
<Spinner className="h-3.5 w-3.5" />
) : (
<TrashSimple size={14} />
)}
</button>
</li>
))}
</ul>
Expand Down
89 changes: 16 additions & 73 deletions frontend/src/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import {
Plus,
TrashSimple,
PaperPlaneTilt,
SpeakerHigh,
Stop,
SidebarSimple,
X,
ShieldCheck,
Expand Down Expand Up @@ -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 {
Expand All @@ -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 (
<>
<Markdown>{body}</Markdown>
Expand Down Expand Up @@ -169,28 +118,8 @@ function AssistantAnswer({ content }: { content: string }) {
</div>
)}

{/* ── 액션 툴바: 읽어주기 + 답변 복사 ── */}
{/* ── 액션 툴바: 답변 복사 ── */}
<div className="mt-3.5 flex items-center gap-2">
<button
onClick={toggleSpeak}
aria-label={speaking ? "읽기 정지" : "답변 읽어주기"}
aria-pressed={speaking}
className={`btn-ghost gap-1.5 rounded-full px-3.5 py-1.5 text-xs sm:text-[13px] ${
speaking ? "border-accent/40 bg-accent/15 text-accent font-semibold" : ""
}`}
>
{speaking ? (
<>
<Stop weight="fill" size={14} />
정지
</>
) : (
<>
<SpeakerHigh weight="fill" size={14} />
읽어주기
</>
)}
</button>
<button
onClick={copyAnswer}
aria-label="답변 복사"
Expand Down Expand Up @@ -323,8 +252,22 @@ function ChatClient() {
);
setMessages(res.messages);
} catch (e) {
toast(`기록 로드 실패: ${errMsg(e)}`, "error");
const msg = errMsg(e);
setMessages([]);
// 세션이 이미 없어진 대화(체크포인트 유실 등)는 목록에 죽은 채로 남겨두지 않고
// 바로 지운다 — 사용자가 매번 눌러서 같은 에러를 다시 보게 두지 않는다.
if (msg.startsWith("404")) {
if (currentId === s.session_id) setCurrentId(null);
try {
await apiDelete(`/api/v1/chat/sessions/${encodeURIComponent(s.session_id)}`);
} catch {
/* 이미 없으면 그것대로 목표 달성 */
}
await refreshSessions();
toast("세션을 찾을 수 없어 대화 기록을 삭제했습니다.", "error");
} else {
toast(`기록 로드 실패: ${msg}`, "error");
}
} finally {
setLoadingHistory(false);
}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,10 @@ export function listRagDocuments(): Promise<{ documents: RagDocument[] }> {
return apiGet("/api/v1/graph/documents");
}

export function deleteRagDocument(source: string): Promise<{ source: string; deleted_passages: number }> {
return apiDelete(`/api/v1/graph/documents/${encodeURIComponent(source)}`);
}

export function ingestDocument(filename: string): Promise<{ job_id: string }> {
return apiPost("/api/v1/graph/ingest/jobs", { filename });
}
Expand Down
17 changes: 14 additions & 3 deletions frontend/src/lib/user-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ const STORAGE_KEY = "midas.selectedUser";

export function UserProvider({ children }: { children: ReactNode }) {
const [selected, setSelectedState] = useState<SelectedUser | null>(null);
// 인증 켜짐이면 토큰 확인 전까지 자식(페이지)을 그리지 않는다. 그리지 않으면 홈 화면의
// GuideTour(가이드 스포트라이트)가 먼저 열려버려, /login으로 밀려나기 전 화면을 어둡게
// 덮는 게 잠깐 보였다 사라지는 문제가 있었다 — 자식 자체를 감춰 원천 차단한다.
const [checking, setChecking] = useState(AUTH_ENABLED);
const router = useRouter();
const pathname = usePathname();

Expand All @@ -57,11 +61,18 @@ export function UserProvider({ children }: { children: ReactNode }) {
// useSearchParams 대신 window.location 을 쓰는 건 이 코드가 이미 effect 안이라
// 브라우저에서만 돌고, 훅을 쓰면 Suspense 경계를 따로 둘러야 하기 때문이다.
useEffect(() => {
if (!AUTH_ENABLED) return;
if (!getToken() && pathname !== "/login") {
// 토큰(localStorage)은 브라우저에서만 읽을 수 있어 effect가 정답 — 렌더 중엔 알 수 없다.
if (!AUTH_ENABLED || pathname === "/login") {
/* eslint-disable-next-line react-hooks/set-state-in-effect */
setChecking(false);
return;
}
if (!getToken()) {
const here = window.location.pathname + window.location.search;
router.replace(`/login?next=${encodeURIComponent(here)}`);
return; // 리다이렉트가 끝날 때까지 자식을 계속 숨긴 채로 둔다.
}
setChecking(false);
}, [pathname, router]);

const setSelected = (u: SelectedUser | null) => {
Expand All @@ -85,7 +96,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
<UserContext.Provider
value={{ selected, setSelected, authEnabled: AUTH_ENABLED, login, logout }}
>
{children}
{checking ? null : children}
</UserContext.Provider>
);
}
Expand Down
12 changes: 12 additions & 0 deletions shared/database/repositories/embeddings.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,15 @@ def list_emb_sources() -> list[dict[str, Any]]:
return [{"source": src, "passages": cnt} for src, cnt in cursor.fetchall()]


def delete_emb_passages_by_source(source: str) -> int:
"""해당 문서(source)의 단락을 emb_passages에서 전부 지운다. 지식베이스 '파일 삭제'용.

Neo4j에 이미 반영된 엔티티/관계는 건드리지 않는다 — 어떤 노드가 이 source에서 나왔는지
추적하지 않아 안전하게 되돌릴 방법이 없다. 필요하면 그래프 재빌드로 다시 정리한다.
"""
sql = "DELETE FROM emb_passages WHERE source = %s;"
with db_cursor() as (_, cursor):
cursor.execute(sql, (source,))
return cursor.rowcount


Loading