From d63a6b85e181cb2d055bf2db8ff08ba37e7f958d Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Fri, 3 Apr 2026 14:03:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20?= =?UTF-8?q?3=EB=8B=A8=EA=B3=84=20=EC=9E=AC=EA=B7=80=20=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=20+=20=EB=84=88=EB=B9=84=20=ED=99=95=EC=9E=A5=20(320px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tree API: domain 경로를 파싱하여 계층 구조로 반환 (Industrial_Safety → Practice → Patrol_Inspection) - Sidebar: 재귀 snippet으로 N단계 트리 렌더링 - domain 필터: prefix 매칭 (상위 클릭 시 하위 전부 포함) - 사이드바 너비: 260px → 320px Co-Authored-By: Claude Opus 4.6 (1M context) --- app/api/documents.py | 61 ++++--- frontend/src/app.css | 2 +- frontend/src/lib/components/Sidebar.svelte | 176 ++++++++------------- 3 files changed, 106 insertions(+), 133 deletions(-) diff --git a/app/api/documents.py b/app/api/documents.py index a57cd5c..5dd494f 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -78,47 +78,59 @@ class DocumentUpdate(BaseModel): # ─── 스키마 (트리) ─── -class SubGroupNode(BaseModel): - sub_group: str +class TreeNode(BaseModel): + name: str + path: str count: int - - -class DomainNode(BaseModel): - domain: str - count: int - children: list[SubGroupNode] + children: list["TreeNode"] # ─── 엔드포인트 ─── -@router.get("/tree", response_model=list[DomainNode]) +@router.get("/tree") async def get_document_tree( user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): - """도메인/sub_group 트리 (사이드바용)""" + """도메인 트리 (3단계 경로 파싱, 사이드바용)""" from sqlalchemy import text as sql_text result = await session.execute( sql_text(""" - SELECT ai_domain, ai_sub_group, COUNT(*) + SELECT ai_domain, COUNT(*) FROM documents - WHERE ai_domain IS NOT NULL - GROUP BY ai_domain, ai_sub_group - ORDER BY ai_domain, ai_sub_group + WHERE ai_domain IS NOT NULL AND ai_domain != '' + GROUP BY ai_domain + ORDER BY ai_domain """) ) - tree: dict[str, DomainNode] = {} - for domain, sub_group, count in result: - if domain not in tree: - tree[domain] = DomainNode(domain=domain, count=0, children=[]) - tree[domain].count += count - if sub_group: - tree[domain].children.append(SubGroupNode(sub_group=sub_group, count=count)) + # 경로를 트리로 파싱 + root: dict = {} + for domain_path, count in result: + parts = domain_path.split("/") + node = root + for part in parts: + if part not in node: + node[part] = {"_count": 0, "_children": {}} + node[part]["_count"] += count + node = node[part]["_children"] - return list(tree.values()) + def build_tree(d: dict, prefix: str = "") -> list[dict]: + nodes = [] + for name, data in sorted(d.items()): + path = f"{prefix}/{name}" if prefix else name + children = build_tree(data["_children"], path) + nodes.append({ + "name": name, + "path": path, + "count": data["_count"], + "children": children, + }) + return nodes + + return build_tree(root) @router.get("/", response_model=DocumentListResponse) @@ -136,9 +148,8 @@ async def list_documents( query = select(Document) if domain: - query = query.where(Document.ai_domain == domain) - if sub_group: - query = query.where(Document.ai_sub_group == sub_group) + # prefix 매칭: Industrial_Safety 클릭 시 하위 전부 포함 + query = query.where(Document.ai_domain.startswith(domain)) if source: query = query.where(Document.source_channel == source) if format: diff --git a/frontend/src/app.css b/frontend/src/app.css index abaaf35..b7f43f0 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -22,7 +22,7 @@ --domain-reference: #fbbf24; /* sidebar */ - --sidebar-w: 260px; + --sidebar-w: 320px; --sidebar-bg: #141720; } diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 65596b2..c47ec0f 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -2,45 +2,28 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { api } from '$lib/api'; - import { ChevronRight, ChevronDown, FolderOpen, Folder, Inbox, Clock, Mail, Scale } from 'lucide-svelte'; + import { ChevronRight, ChevronDown, FolderOpen, Inbox, Clock, Mail, Scale } from 'lucide-svelte'; let tree = $state([]); - let inboxCount = $state(0); let loading = $state(true); let expanded = $state({}); - // 현재 URL에서 선택된 domain/sub_group let activeDomain = $derived($page.url.searchParams.get('domain')); - let activeSubGroup = $derived($page.url.searchParams.get('sub_group')); - // domain별 색상 const DOMAIN_COLORS = { - 'Knowledge/Philosophy': 'var(--domain-philosophy)', - 'Knowledge/Language': 'var(--domain-language)', - 'Knowledge/Engineering': 'var(--domain-engineering)', - 'Knowledge/Industrial_Safety': 'var(--domain-safety)', - 'Knowledge/Programming': 'var(--domain-programming)', - 'Knowledge/General': 'var(--domain-general)', + 'Philosophy': 'var(--domain-philosophy)', + 'Language': 'var(--domain-language)', + 'Engineering': 'var(--domain-engineering)', + 'Industrial_Safety': 'var(--domain-safety)', + 'Programming': 'var(--domain-programming)', + 'General': 'var(--domain-general)', 'Reference': 'var(--domain-reference)', }; - // domain 표시 이름 (짧게) - function displayName(domain) { - return domain.replace('Knowledge/', ''); - } - async function loadTree() { loading = true; try { - const [treeData, docsData] = await Promise.all([ - api('/documents/tree'), - api('/documents/?page_size=1&domain='), - ]); - tree = treeData; - // inbox count: file_path가 PKM/Inbox/로 시작하는 문서 - const inboxData = await api('/documents/?page_size=1'); - // tree에서 전체 count 합산 - inboxCount = 0; // 별도 API 없으면 0으로 시작 + tree = await api('/documents/tree'); } catch (err) { console.error('트리 로딩 실패:', err); } finally { @@ -48,57 +31,44 @@ } } - function toggleExpand(domain) { - expanded[domain] = !expanded[domain]; + function toggleExpand(path) { + expanded[path] = !expanded[path]; } - function navigate(domain, subGroup) { + function navigate(path) { const params = new URLSearchParams($page.url.searchParams); - // 필터 변경 시 page reset params.delete('page'); - - if (domain) { - params.set('domain', domain); + if (path) { + params.set('domain', path); } else { params.delete('domain'); } - - if (subGroup) { - params.set('sub_group', subGroup); - } else { - params.delete('sub_group'); - } - - // 빈 값 정규화 + params.delete('sub_group'); for (const [key, val] of [...params.entries()]) { if (!val) params.delete(key); } - const qs = params.toString(); goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true }); } - function clearFilters() { - goto('/documents', { noScroll: true }); - } - - // 초기 로딩 + 선택된 domain은 자동 펼치기 - $effect(() => { - loadTree(); - }); + $effect(() => { loadTree(); }); $effect(() => { if (activeDomain) { - expanded[activeDomain] = true; + // 선택된 경로의 부모들 자동 펼치기 + const parts = activeDomain.split('/'); + let path = ''; + for (const part of parts) { + path = path ? `${path}/${part}` : part; + expanded[path] = true; + } } }); - // 전체 문서 수 - let totalCount = $derived(tree.reduce((sum, node) => sum + node.count, 0)); + let totalCount = $derived(tree.reduce((sum, n) => sum + n.count, 0));