feat: 사이드바 3단계 재귀 트리 + 너비 확장 (320px)

- tree API: domain 경로를 파싱하여 계층 구조로 반환
  (Industrial_Safety → Practice → Patrol_Inspection)
- Sidebar: 재귀 snippet으로 N단계 트리 렌더링
- domain 필터: prefix 매칭 (상위 클릭 시 하위 전부 포함)
- 사이드바 너비: 260px → 320px

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-03 14:03:36 +09:00
parent bf0506023c
commit d63a6b85e1
3 changed files with 106 additions and 133 deletions

View File

@@ -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: