From 92c6f0983fe88630ebb2b150686440102b1df606 Mon Sep 17 00:00:00 2001 From: HONGDAE KIM Date: Sun, 15 Feb 2026 12:43:56 +0900 Subject: [PATCH] Standardize UI status/error handling and API request structure --- NEXT_STEPS_UI_AND_BACKLOG.md | 152 ---------- README.md | 17 +- REVIEW_REPORT.md | 83 ------ bitnet_tools/ui/app.js | 540 +++++++++++++++++++++++++---------- bitnet_tools/ui/index.html | 14 +- bitnet_tools/ui/styles.css | 32 +++ bitnet_tools/web.py | 17 +- 7 files changed, 446 insertions(+), 409 deletions(-) delete mode 100644 NEXT_STEPS_UI_AND_BACKLOG.md delete mode 100644 REVIEW_REPORT.md diff --git a/NEXT_STEPS_UI_AND_BACKLOG.md b/NEXT_STEPS_UI_AND_BACKLOG.md deleted file mode 100644 index 00ba639..0000000 --- a/NEXT_STEPS_UI_AND_BACKLOG.md +++ /dev/null @@ -1,152 +0,0 @@ -# BitNet Analyzer 다음 단계 작업 정리 - -이 문서는 다음 턴부터 바로 실행할 수 있도록 -1) 인터페이스 고도화 진행 단계, -2) 전체 남은 과제(개선권장/코드 최적화/입력 포맷 확장) -를 한 번에 정리한 체크리스트입니다. - ---- - -## A. 인터페이스 고도화 진행 단계 (다음 턴부터 착수) - -### Phase 1 — 인터페이스 단순화(우선) -목표: "처음 들어온 사용자도 바로 분석" 가능한 화면으로 정리 - -- [ ] 화면을 2모드로 분리 - - [ ] 빠른 시작 모드: 파일 입력 + 자연어 요청 + 실행 버튼 1개 - - [ ] 고급 모드: 그룹/타깃, JSON 대시보드, 모델 태그 등 -- [ ] 섹션 구조 재배치 - - [ ] 입력 - - [ ] 실행 상태 - - [ ] 결과 -- [ ] 초기에 보이는 요소 축소 - - [ ] 멀티 분석/JSON 대시보드는 접기(Accordion) 기본 - -완료 기준: -- 신규 사용자가 30초 내 첫 분석 실행 가능 -- 단일 분석 중심 플로우에서 클릭 수 감소 - -### Phase 2 — 자연어 요청 인터페이스 -목표: 사용자의 작업 지시를 자연어로 받아 흐름 자동 선택 - -- [ ] "작업 요청" 입력창 추가 (예: "이 파일 먼저 분석하고 시각화 옵션 알려줘") -- [ ] 의도 라우팅 규칙 추가 - - [ ] 분석 우선 - - [ ] 시각화 옵션 안내 우선 - - [ ] 멀티 비교 우선 -- [ ] 질문(question)과 작업요청(intent) 분리 저장 - -완료 기준: -- 자연어 요청 한 번으로 기본 작업 흐름 자동 진행 -- 요청 해석 실패 시 추천 액션 버튼 제공 - -### Phase 3 — 상태/피드백 표준화 -목표: 진행상황/실패 원인/다음 행동을 명확히 안내 - -- [ ] 로딩 상태 통합 컴포넌트 - - [ ] 분석 중 - - [ ] BitNet 실행 중 - - [ ] 멀티 분석 중 - - [ ] 차트 생성 중 -- [ ] 에러 메시지 표준화 - - [ ] 사용자용 간단 메시지 - - [ ] 상세 기술 메시지(접기) -- [ ] 버튼 활성/비활성 규칙 정리 - -완료 기준: -- 실패 시 사용자가 다음 액션을 바로 알 수 있음 -- 중복 클릭/중복 요청 최소화 - -### Phase 4 — 대시보드 사용성 강화 -목표: 결과 확인 시간을 줄이고 탐색 효율 개선 - -- [ ] KPI 카드 + 인사이트 리스트 개선 -- [ ] 필터 추가 - - [ ] 파일 단위 - - [ ] 컬럼명 검색 - - [ ] 인사이트 유형(결측/이상치/드리프트) -- [ ] 드릴다운 패널 추가 - - [ ] 선택 인사이트 근거 데이터 표기 - -완료 기준: -- 핵심 인사이트 3개 확인 시간 단축 -- 사용자가 근거를 UI에서 즉시 확인 가능 - -### Phase 5 — 비동기 차트 UX 연동 -목표: 대형 데이터에서도 차트 생성 경험 안정화 - -- [ ] 차트 job 상태 표시(queued/running/done/failed) -- [ ] polling + 완료 시 결과 자동 갱신 -- [ ] 실패 재시도 버튼 - -완료 기준: -- 차트 생성 대기 중에도 UI 사용 가능 -- 실패 복구 동선 제공 - ---- - -## B. 남은 과제 백로그 (우선순위 포함) - -## B-1. 분석 정확도 개선 - -### P1. 날짜 파싱 포맷 확장 -- [ ] 날짜 포맷 후보 확장 (`YYYYMMDD`, `DD-MM-YYYY`, locale 변형) -- [ ] 모호한 포맷은 불확실 상태로 분리 - -### P1. 결과 스키마 버전 필드 도입 -- [ ] JSON 결과에 `schema_version` 추가 -- [ ] 버전 변경 로그 문서화 - -### P2. 고유값 추정 정밀 옵션 -- [ ] 기본(빠른 추정) + 정밀 모드(옵션) 이원화 -- [ ] 결과에 추정 방식 메타데이터 포함 - -## B-2. 코드 최적화/구조화 - -### P1. UI 스크립트 모듈화 -- [ ] `app.js`를 기능 단위로 분리 - - [ ] 상태관리 - - [ ] API 호출 - - [ ] 렌더링 - - [ ] 이벤트 바인딩 - -### P1. API/에러 핸들링 공통화 -- [ ] 공통 fetch wrapper -- [ ] 에러 포맷 표준(JSON) - -### P2. 성능 개선 -- [ ] 대형 JSON 렌더 incremental 처리 -- [ ] 불필요한 재렌더 최소화 - -## B-3. 입력 파일 확장성 (후순위) - -현재 직접 지원은 CSV 중심이므로, 아래를 순차 확장: - -### P2. Excel 지원 (`.xlsx`, `.xls`) -- [ ] 시트 선택 UI -- [ ] 표 데이터 CSV 정규화 - -### P3. 문서 포맷 지원 (`.pdf`, `.docx`, `.pptx`) -- [ ] 표 추출 파이프라인 도입 -- [ ] 추출 정확도/신뢰도 표시 -- [ ] 실패 시 CSV 업로드 fallback 제공 - ---- - -## C. 다음 턴 실행 순서 (권장) - -1. Phase 1 (단순화) 착수 -2. Phase 2 (자연어 요청 인터페이스) -3. Phase 3 (상태/에러 표준화) -4. Phase 4 (대시보드 필터/드릴다운) -5. Phase 5 (차트 비동기 UX) - -병행 과제로 `schema_version` 설계를 함께 진행. - ---- - -## D. 체크 포인트 - -- [ ] 기능 개발 후 `pytest -q` 통과 -- [ ] UI 주요 흐름 스모크 테스트 -- [ ] 문서 업데이트(README/변경 이력) diff --git a/README.md b/README.md index 8b498ed..eede2d6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## 0) 현재 완성도 빠른 진단 -현 시점 기준 기능 완성도(실사용 관점): **약 92%** +현 시점 기준 기능 완성도(실사용 관점): **약 88%** - 완료 - CSV 기초 요약(행/열/결측/숫자 통계) @@ -18,12 +18,15 @@ - 인사이트 룰 엔진(결측/이상치/드리프트 경고) - 파일 프로파일 캐시(.bitnet_cache)로 재분석 가속 - 다중 CSV 자동 시각화 차트 생성(histogram/boxplot/top bar/scatter/missing-bar, matplotlib 설치 시) - - 브라우저 UI(`bitnet-analyze ui`) - - 웹 UI 대시보드(JSON 붙여넣기 기반 KPI/인사이트 뷰) - - **윈도우 데스크톱 UI(`bitnet-analyze desktop`, `BitNet_Desktop_Start.bat`)** -- 남은 과제 - - 대시보드 필터/드릴다운 고도화 - - 차트 렌더링 백엔드 비동기 작업 큐(대형 배치용) + - 브라우저 UI(`bitnet-analyze ui`) / 웹 대시보드 / Windows 데스크톱 UI + +- 남은 과제 (우선순위) + - **P1 UI 개선**: 화면 2모드(빠른 시작/고급), 상태·에러 표준화, 대시보드 필터/드릴다운 강화 + - **P1 분석 품질**: 날짜 파싱 포맷 확장(`YYYYMMDD`, `DD-MM-YYYY` 등), 결과 `schema_version` 도입 + - **P1 구조화**: UI 스크립트 모듈화(상태관리/API/렌더링/이벤트), 공통 fetch/에러 포맷 정리 + - **P2 성능**: 대형 JSON incremental 렌더링, 차트 비동기 job UX(queued/running/done/failed, 재시도) + - **P2 입력 확장**: Excel(`.xlsx`, `.xls`) 시트 선택·CSV 정규화 + - **P3 파일 확장**: 문서 포맷(`.pdf`, `.docx`, `.pptx`) 표 추출 + 실패 시 CSV fallback ### 처리 규모 가이드 diff --git a/REVIEW_REPORT.md b/REVIEW_REPORT.md deleted file mode 100644 index 4e0caed..0000000 --- a/REVIEW_REPORT.md +++ /dev/null @@ -1,83 +0,0 @@ -# 실행 가능률 및 분석 기능 점검 보고서 - -## 1) 현재 실행 가능률(환경 기준) - -- 테스트 실행 결과: `24 passed` (핵심 분석/CLI/웹 핸들러 단위 기능 정상) -- 로컬 환경 진단 결과: Python/플랫폼 확인 가능, `ollama` 미설치로 모델 실행 경로는 현재 비활성 - -### 실행 가능률 산정(현 환경) -- 코드 자체 품질(테스트 통과율): **100% (24/24)** -- LLM 연동 포함 엔드투엔드 실사용률: **약 85~90%** - - 사유: `analyze`, `multi-analyze`, `report`, `ui`, `doctor`는 동작 가능 - - 단, `ollama run`이 필요한 즉시 모델 응답(`/api/run`, `--model`)은 로컬 ollama 설치/기동 필요 - -## 2) 제공 분석 기능 - -### 단일 CSV 분석 -- 행/열 수, 컬럼 목록, 결측 수 -- 컬럼 타입 추론(숫자/문자) -- 숫자형 기본 통계(count/mean/min/max) -- BitNet 프롬프트 자동 생성 -- Markdown 보고서 생성 - -### 다중 CSV 분석 -- 파일별 프로파일링(결측/고유비율/대표값/의미타입) -- 공통 컬럼/전체 컬럼 집합 비교 -- 파일 간 스키마 드리프트(타입 변화, 결측비율 범위, 대표값비율 범위, 평균 변화) -- 인사이트 룰 엔진(결측 높음, 이상치 비율 높음, 평균 변화 등) -- 그룹-타깃 비율표(옵션) -- pandas 코드 가이드 자동 생성 -- 캐시 기반 재분석 가속(`.bitnet_cache`) -- 병렬 파일 프로파일링(workers) - -### 시각화 -- 수치형: histogram, boxplot, missing bar -- 범주형: top-k bar -- 수치형 2개 이상 시 scatter 샘플 -- 대용량 대응을 위한 reservoir sampling 적용 - -### 웹/데스크톱 -- 브라우저 UI: CSV 텍스트 붙여넣기 단일/다중 분석 -- 차트 생성 비동기 작업(job) 제출/조회 API -- Windows 데스크톱 UI 진입점 제공 - -## 3) 받아본 데이터로 도출 가능한 결과 - -- 데이터 품질 진단: 결측/편중/희소성/대표값 쏠림 -- 수치 분포 요약: 평균/최솟값/최댓값/양수·0·음수 비율 -- 이상치 위험도: IQR 기반 outlier ratio 추정 -- 컬럼 성격 파악: category/date/numeric/text/위경도 추정 -- 다중 파일 비교: 스키마 호환성 및 분포 변화(드리프트) -- 운영 인사이트: 품질 이슈 우선순위(결측↑, 이상치↑, 타입 충돌) -- 후속 분석 가이드: 병합 키 중심 pandas 예시 코드 자동 제안 - -## 4) 분석 가능한 데이터 범위 - -### 직접 지원 -- CSV 파일(단일/다중) -- 웹 UI 입력용 CSV 텍스트(붙여넣기) - -### 확장/간접 지원 -- 생성된 JSON/Markdown 결과를 BitNet 프롬프트로 전달해 해석형 요약 가능 -- 코드 가이드를 활용한 pandas 후처리 확장 가능 - -### 주의사항 -- CSV 헤더 필수(헤더 없으면 오류) -- 숫자형은 컬럼 내 텍스트 혼입 시 string으로 판정될 수 있음 -- 고유값은 비트맵 기반 추정치(정확 cardinality 아님) -- outlier ratio는 샘플 기반 추정 - -## 5) 코드 전체 검토 요약 - -### 강점 -- 분석 엔진이 스트리밍/샘플링 중심으로 메모리 사용을 제어 -- CLI, 웹 API, 데스크톱 진입점이 분리되어 사용성 좋음 -- 캐시/병렬 처리 등 실사용 성능 요소 반영 -- 테스트 커버리지(기능 단위) 양호 - -### 개선 권장 -- 숫자형 추론 고도화(천 단위 구분기호, 퍼센트, 통화 기호 정규화) -- 날짜 파싱 포맷 확장 및 locale 대응 -- unique 추정 정확도 옵션(HLL 등) 추가 -- UI/API 레벨에서 대용량 파일 업로드/진행률/취소 제어 강화 -- 결과 스키마 버전 필드 추가(하위호환 관리) diff --git a/bitnet_tools/ui/app.js b/bitnet_tools/ui/app.js index f550147..ba19830 100644 --- a/bitnet_tools/ui/app.js +++ b/bitnet_tools/ui/app.js @@ -1,202 +1,426 @@ -const csvFile = document.getElementById('csvFile'); -const csvText = document.getElementById('csvText'); -const question = document.getElementById('question'); -const model = document.getElementById('model'); -const analyzeBtn = document.getElementById('analyzeBtn'); -const quickAnalyzeBtn = document.getElementById('quickAnalyzeBtn'); -const runBtn = document.getElementById('runBtn'); -const summary = document.getElementById('summary'); -const prompt = document.getElementById('prompt'); -const answer = document.getElementById('answer'); -const statusBox = document.getElementById('statusBox'); - -const multiCsvFiles = document.getElementById('multiCsvFiles'); -const groupColumn = document.getElementById('groupColumn'); -const targetColumn = document.getElementById('targetColumn'); -const multiAnalyzeBtn = document.getElementById('multiAnalyzeBtn'); -const dashboardJson = document.getElementById('dashboardJson'); -const dashboardCards = document.getElementById('dashboardCards'); -const dashboardInsights = document.getElementById('dashboardInsights'); - -let latestPrompt = ''; +const UI = { + csvFile: document.getElementById('csvFile'), + csvText: document.getElementById('csvText'), + question: document.getElementById('question'), + intent: document.getElementById('intent'), + intentActions: document.getElementById('intentActions'), + model: document.getElementById('model'), + analyzeBtn: document.getElementById('analyzeBtn'), + quickAnalyzeBtn: document.getElementById('quickAnalyzeBtn'), + runBtn: document.getElementById('runBtn'), + summary: document.getElementById('summary'), + prompt: document.getElementById('prompt'), + answer: document.getElementById('answer'), + statusBox: document.getElementById('statusBox'), + modeGuide: document.getElementById('modeGuide'), + errorUser: document.getElementById('errorUser'), + errorDetails: document.getElementById('errorDetails'), + errorDetailText: document.getElementById('errorDetailText'), + multiCsvFiles: document.getElementById('multiCsvFiles'), + groupColumn: document.getElementById('groupColumn'), + targetColumn: document.getElementById('targetColumn'), + multiAnalyzeBtn: document.getElementById('multiAnalyzeBtn'), + dashboardJson: document.getElementById('dashboardJson'), + dashboardCards: document.getElementById('dashboardCards'), + dashboardInsights: document.getElementById('dashboardInsights'), + renderDashboardBtn: document.getElementById('renderDashboardBtn'), + copyPromptBtn: document.getElementById('copyPrompt'), +}; + +const STATUS = { + quickReady: '빠른 시작: 입력 → 요청 확인 → 바로 분석', + advancedReady: '고급 모드: 모델 실행/멀티 분석/대시보드를 사용할 수 있습니다.', + analyzing: '분석 중...', + analyzeDone: '분석 완료', + modelRunning: 'BitNet 실행 중...', + modelDone: 'BitNet 실행 완료', + multiRunning: '멀티 분석 중...', + multiDone: '멀티 분석 완료', + dashboardDone: '대시보드 렌더 완료', +}; + +const USER_ERROR = { + noPrompt: '먼저 분석을 실행해 프롬프트를 생성하세요.', + noModel: '모델 태그를 입력하세요. 예: bitnet:latest', + invalidDashboardJson: '대시보드 JSON 형식이 올바르지 않습니다.', + noMultiFiles: '멀티 CSV 파일을 먼저 선택하세요.', + unknownIntent: '의도 해석이 불명확합니다. 아래 추천 액션 중 하나를 선택하세요.', +}; + +const appState = { + latestPrompt: '', + currentMode: 'quick', + busyCount: 0, + request: { question: '', intent: '', route: 'analyze' }, +}; function setStatus(message) { - if (statusBox) statusBox.textContent = message; + if (UI.statusBox) UI.statusBox.textContent = message; } -function setMode(mode) { - const advancedOnly = document.querySelectorAll('.advanced-only'); - advancedOnly.forEach((el) => { - el.style.display = mode === 'advanced' ? '' : 'none'; - }); +function showError(userMessage, detail = '') { + if (UI.errorUser) UI.errorUser.textContent = userMessage || ''; + if (!UI.errorDetails || !UI.errorDetailText) return; + UI.errorDetailText.textContent = detail || ''; + UI.errorDetails.open = false; + UI.errorDetails.style.display = detail ? '' : 'none'; +} - document.querySelectorAll('.mode-btn').forEach((btn) => { - btn.classList.toggle('active', btn.dataset.mode === mode); +function clearError() { + showError('', ''); +} + +function toggleBusy(isBusy) { + appState.busyCount += isBusy ? 1 : -1; + if (appState.busyCount < 0) appState.busyCount = 0; + const disabled = appState.busyCount > 0; + const targets = [ + UI.csvFile, + UI.analyzeBtn, + UI.quickAnalyzeBtn, + UI.runBtn, + UI.multiAnalyzeBtn, + UI.renderDashboardBtn, + ...document.querySelectorAll('.mode-btn'), + ...document.querySelectorAll('.chip'), + ]; + targets.forEach((el) => { + if (el) el.disabled = disabled; }); +} - if (mode === 'quick') { - setStatus('빠른 시작 모드: 파일 입력 후 "바로 분석"을 눌러주세요.'); - } else { - setStatus('고급 모드: 모델 실행, 멀티 분석, 대시보드를 사용할 수 있습니다.'); +async function postJson(url, body, context) { + let res; + let data = null; + try { + res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + data = await res.json(); + } catch (err) { + throw { + userMessage: `${context} 중 네트워크 오류가 발생했습니다.`, + detail: err instanceof Error ? err.message : String(err), + }; + } + + if (!res.ok) { + throw { + userMessage: `${context}에 실패했습니다.`, + detail: data?.error_detail || data?.error || JSON.stringify(data || {}), + }; } + return data; } -document.querySelectorAll('.mode-btn').forEach((btn) => { - btn.addEventListener('click', () => setMode(btn.dataset.mode)); -}); +function saveRequestState(route) { + appState.request.question = UI.question?.value || ''; + appState.request.intent = UI.intent?.value || ''; + appState.request.route = route; +} + +function classifyIntent(intentText) { + const text = String(intentText || '').toLowerCase().trim(); + if (!text) return { route: 'analyze' }; + if (/(멀티|여러|복수|비교|비교분석|multi)/.test(text)) return { route: 'multi' }; + if (/(시각화|차트|그래프|plot|대시보드)/.test(text)) return { route: 'visualize' }; + if (/(분석|요약|인사이트|이상치|진단|핵심)/.test(text)) return { route: 'analyze' }; + return { route: 'unknown' }; +} -if (csvFile) { - csvFile.addEventListener('change', async (e) => { - const file = e.target.files?.[0]; - if (!file) return; - csvText.value = await file.text(); - setStatus(`파일 로드 완료: ${file.name}`); +function renderIntentActions(actions = []) { + if (!UI.intentActions) return; + UI.intentActions.innerHTML = ''; + actions.forEach((item) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'chip'; + btn.textContent = item.label; + btn.addEventListener('click', item.onClick); + UI.intentActions.appendChild(btn); }); } -document.querySelectorAll('.chip').forEach((chip) => { - chip.addEventListener('click', () => { - question.value = chip.dataset.q; +function showFallbackIntentActions() { + renderIntentActions([ + { + label: '기본 분석 실행', + onClick: () => { + setMode('quick'); + UI.quickAnalyzeBtn?.focus(); + setStatus('추천 액션: 기본 분석 실행'); + }, + }, + { + label: '멀티 분석으로 전환', + onClick: () => { + setMode('advanced'); + UI.multiCsvFiles?.focus(); + setStatus('추천 액션: 멀티 분석 파일을 선택하세요.'); + }, + }, + { + label: '시각화 안내 보기', + onClick: () => { + setMode('advanced'); + UI.dashboardJson?.focus(); + setStatus('추천 액션: 멀티 분석 결과 JSON을 붙여넣고 대시보드를 렌더링하세요.'); + }, + }, + ]); +} + +function renderModeGuide(mode) { + if (!UI.modeGuide) return; + const steps = mode === 'quick' + ? [ + '1) CSV 파일을 선택하거나 CSV 텍스트를 붙여넣기', + '2) 질문(question)과 작업 요청(intent) 입력', + '3) "바로 분석" 클릭 후 요약 결과 확인', + ] + : [ + '1) 기본 분석을 먼저 실행해 프롬프트 생성', + '2) 필요 시 모델 태그 입력 후 BitNet 실행', + '3) 멀티 CSV/대시보드 고급 기능 활용', + ]; + UI.modeGuide.innerHTML = steps.map((step) => `
  • ${step}
  • `).join(''); +} + +function setMode(mode) { + appState.currentMode = mode; + document.querySelectorAll('.advanced-only').forEach((el) => { + el.style.display = mode === 'advanced' ? '' : 'none'; }); -}); + document.querySelectorAll('.mode-btn').forEach((btn) => { + btn.classList.toggle('active', btn.dataset.mode === mode); + }); + setStatus(mode === 'quick' ? STATUS.quickReady : STATUS.advancedReady); + renderModeGuide(mode); +} -const copyPromptBtn = document.getElementById('copyPrompt'); -if (copyPromptBtn) { - copyPromptBtn.addEventListener('click', async () => { - if (!latestPrompt) return; - await navigator.clipboard.writeText(latestPrompt); - setStatus('프롬프트가 복사되었습니다.'); +function renderDashboard(data) { + if (!UI.dashboardCards || !UI.dashboardInsights) return; + UI.dashboardCards.innerHTML = ''; + UI.dashboardInsights.textContent = ''; + + const cardItems = [ + ['파일 수', data.file_count ?? '-'], + ['총 행 수', data.total_row_count ?? '-'], + ['공통 컬럼 수', (data.shared_columns || []).length], + ['인사이트 수', (data.insights || []).length], + ]; + + cardItems.forEach(([k, v]) => { + const div = document.createElement('div'); + div.className = 'card'; + div.innerHTML = `${k}${v}`; + UI.dashboardCards.appendChild(div); }); + + const insights = data.insights || []; + UI.dashboardInsights.textContent = insights.length + ? insights.map((x, i) => `${i + 1}. ${x}`).join('\n') + : '인사이트 항목이 없습니다.'; + setStatus(STATUS.dashboardDone); } async function runAnalyze() { - setStatus('분석 중...'); - summary.textContent = '분석 중...'; - const res = await fetch('/api/analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - csv_text: csvText.value, - question: question.value, - }), - }); - const data = await res.json(); - if (!res.ok) { - summary.textContent = data.error || 'error'; - setStatus(`분석 실패: ${data.error || 'error'}`); + clearError(); + setStatus(STATUS.analyzing); + UI.summary.textContent = STATUS.analyzing; + toggleBusy(true); + try { + const data = await postJson('/api/analyze', { + csv_text: UI.csvText.value, + question: UI.question.value, + }, '분석'); + appState.latestPrompt = data.prompt; + UI.summary.textContent = JSON.stringify(data.summary, null, 2); + if (UI.prompt) UI.prompt.textContent = data.prompt; + if (UI.answer) UI.answer.textContent = ''; + setStatus(STATUS.analyzeDone); + } catch (err) { + UI.summary.textContent = err.userMessage || '오류'; + showError(err.userMessage || '분석 실패', err.detail || ''); + setStatus('분석 실패'); + } finally { + toggleBusy(false); + } +} + +async function runMultiAnalyze() { + clearError(); + const files = [...(UI.multiCsvFiles?.files || [])]; + if (!files.length) { + UI.dashboardInsights.textContent = USER_ERROR.noMultiFiles; + showError(USER_ERROR.noMultiFiles, 'input files is empty'); + setStatus('멀티 분석 중단'); + return false; + } + + setStatus(STATUS.multiRunning); + UI.dashboardInsights.textContent = STATUS.multiRunning; + toggleBusy(true); + try { + const payloadFiles = []; + for (const f of files) { + payloadFiles.push({ name: f.name, csv_text: await f.text() }); + } + + const data = await postJson('/api/multi-analyze', { + files: payloadFiles, + question: UI.question.value, + group_column: UI.groupColumn.value.trim(), + target_column: UI.targetColumn.value.trim(), + }, '멀티 분석'); + + UI.dashboardJson.value = JSON.stringify(data, null, 2); + renderDashboard(data); + setStatus(STATUS.multiDone); + return true; + } catch (err) { + UI.dashboardInsights.textContent = err.userMessage || '멀티 분석 실패'; + showError(err.userMessage || '멀티 분석 실패', err.detail || ''); + setStatus('멀티 분석 실패'); + return false; + } finally { + toggleBusy(false); + } +} + +async function runModel() { + clearError(); + if (!appState.latestPrompt) { + if (UI.answer) UI.answer.textContent = USER_ERROR.noPrompt; + showError(USER_ERROR.noPrompt, 'latestPrompt is empty'); + setStatus('모델 실행 중단'); + return; + } + if (!UI.model.value.trim()) { + if (UI.answer) UI.answer.textContent = USER_ERROR.noModel; + showError(USER_ERROR.noModel, 'model input is empty'); + setStatus('모델 실행 중단'); return; } - latestPrompt = data.prompt; - summary.textContent = JSON.stringify(data.summary, null, 2); - if (prompt) prompt.textContent = data.prompt; - if (answer) answer.textContent = ''; - setStatus('분석 완료'); + setStatus(STATUS.modelRunning); + if (UI.answer) UI.answer.textContent = STATUS.modelRunning; + toggleBusy(true); + try { + const data = await postJson('/api/run', { + model: UI.model.value.trim(), + prompt: appState.latestPrompt, + }, 'BitNet 실행'); + UI.answer.textContent = data.answer; + setStatus(STATUS.modelDone); + } catch (err) { + UI.answer.textContent = err.userMessage || '모델 실행 실패'; + showError(err.userMessage || '모델 실행 실패', err.detail || ''); + setStatus('모델 실행 실패'); + } finally { + toggleBusy(false); + } } -if (analyzeBtn) analyzeBtn.addEventListener('click', runAnalyze); -if (quickAnalyzeBtn) quickAnalyzeBtn.addEventListener('click', runAnalyze); +async function runByIntent() { + clearError(); + renderIntentActions([]); + const intentResult = classifyIntent(UI.intent?.value || ''); + saveRequestState(intentResult.route); -if (runBtn) { - runBtn.addEventListener('click', async () => { - if (!latestPrompt) { - if (answer) answer.textContent = '먼저 분석을 실행해 프롬프트를 생성하세요.'; - setStatus('모델 실행 중단: 프롬프트가 없습니다.'); - return; - } - if (!model.value.trim()) { - if (answer) answer.textContent = '모델 태그를 입력하세요. 예: bitnet:latest'; - setStatus('모델 실행 중단: 모델 태그가 없습니다.'); - return; + if (intentResult.route === 'analyze') return runAnalyze(); + + if (intentResult.route === 'multi') { + setMode('advanced'); + const done = await runMultiAnalyze(); + if (!done) { + renderIntentActions([ + { label: '멀티 파일 선택하기', onClick: () => UI.multiCsvFiles?.focus() }, + { label: '기본 분석으로 진행', onClick: () => { setMode('quick'); runAnalyze(); } }, + ]); + setStatus('의도 라우팅: 멀티 비교 우선'); } + return; + } - setStatus('BitNet 실행 중...'); - if (answer) answer.textContent = 'BitNet 실행 중...'; - const res = await fetch('/api/run', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: model.value.trim(), prompt: latestPrompt }), - }); - const data = await res.json(); - if (answer) answer.textContent = res.ok ? data.answer : (data.error || 'error'); - setStatus(res.ok ? 'BitNet 실행 완료' : `BitNet 실행 실패: ${data.error || 'error'}`); - }); + if (intentResult.route === 'visualize') { + setMode('advanced'); + renderIntentActions([ + { label: '대시보드 JSON 입력으로 이동', onClick: () => UI.dashboardJson?.focus() }, + { label: '먼저 멀티 분석 실행', onClick: () => UI.multiAnalyzeBtn?.focus() }, + ]); + setStatus('의도 라우팅: 시각화 안내 우선'); + return; + } + + showFallbackIntentActions(); + showError(USER_ERROR.unknownIntent, `intent="${UI.intent?.value || ''}"`); + setStatus('의도 라우팅 실패'); } -const renderDashboardBtn = document.getElementById('renderDashboardBtn'); -if (renderDashboardBtn) { - renderDashboardBtn.addEventListener('click', () => { - dashboardCards.innerHTML = ''; - dashboardInsights.textContent = ''; +function bindEvents() { + document.querySelectorAll('.mode-btn').forEach((btn) => { + btn.addEventListener('click', () => setMode(btn.dataset.mode)); + }); - let parsed; - try { - parsed = JSON.parse(dashboardJson.value || '{}'); - } catch { - dashboardInsights.textContent = 'JSON 형식이 올바르지 않습니다.'; - setStatus('대시보드 렌더 실패: JSON 형식 오류'); - return; - } + if (UI.csvFile) { + UI.csvFile.addEventListener('change', async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + UI.csvText.value = await file.text(); + setStatus(`파일 로드 완료: ${file.name}`); + }); + } - const cardItems = [ - ['파일 수', parsed.file_count ?? '-'], - ['총 행 수', parsed.total_row_count ?? '-'], - ['공통 컬럼 수', (parsed.shared_columns || []).length], - ['인사이트 수', (parsed.insights || []).length], - ]; - - cardItems.forEach(([k, v]) => { - const div = document.createElement('div'); - div.className = 'card'; - div.innerHTML = `${k}${v}`; - dashboardCards.appendChild(div); + document.querySelectorAll('.chip[data-q]').forEach((chip) => { + chip.addEventListener('click', () => { + UI.question.value = chip.dataset.q; + UI.quickAnalyzeBtn?.focus(); }); + }); - const insights = parsed.insights || []; - dashboardInsights.textContent = insights.length - ? insights.map((x, i) => `${i + 1}. ${x}`).join('\n') - : '인사이트 항목이 없습니다.'; - setStatus('대시보드 렌더 완료'); + UI.copyPromptBtn?.addEventListener('click', async () => { + if (!appState.latestPrompt) return; + await navigator.clipboard.writeText(appState.latestPrompt); + setStatus('프롬프트가 복사되었습니다.'); }); -} -if (multiAnalyzeBtn) { - multiAnalyzeBtn.addEventListener('click', async () => { - const files = [...(multiCsvFiles.files || [])]; - if (!files.length) { - dashboardInsights.textContent = '멀티 CSV 파일을 먼저 선택하세요.'; - setStatus('멀티 분석 중단: 파일 없음'); - return; - } + UI.analyzeBtn?.addEventListener('click', runByIntent); + UI.quickAnalyzeBtn?.addEventListener('click', runByIntent); + UI.runBtn?.addEventListener('click', runModel); + UI.multiAnalyzeBtn?.addEventListener('click', runMultiAnalyze); - setStatus('멀티 분석 중...'); - dashboardInsights.textContent = '멀티 분석 중...'; - const payloadFiles = []; - for (const f of files) { - payloadFiles.push({ name: f.name, csv_text: await f.text() }); + UI.renderDashboardBtn?.addEventListener('click', () => { + clearError(); + try { + const parsed = JSON.parse(UI.dashboardJson.value || '{}'); + renderDashboard(parsed); + } catch (err) { + UI.dashboardInsights.textContent = USER_ERROR.invalidDashboardJson; + showError(USER_ERROR.invalidDashboardJson, err instanceof Error ? err.message : String(err)); + setStatus('대시보드 렌더 실패'); } + }); - const res = await fetch('/api/multi-analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - files: payloadFiles, - question: question.value, - group_column: groupColumn.value.trim(), - target_column: targetColumn.value.trim(), - }), - }); - const data = await res.json(); - if (!res.ok) { - dashboardInsights.textContent = data.error || 'error'; - setStatus(`멀티 분석 실패: ${data.error || 'error'}`); + UI.intent?.addEventListener('input', () => { + if (!UI.intent.value.trim()) { + renderIntentActions([]); return; } - - dashboardJson.value = JSON.stringify(data, null, 2); - renderDashboardBtn.click(); - setStatus('멀티 분석 완료'); + const { route } = classifyIntent(UI.intent.value); + if (route === 'analyze') setStatus('의도 라우팅 후보: 분석 우선'); + else if (route === 'multi') setStatus('의도 라우팅 후보: 멀티 비교 우선'); + else if (route === 'visualize') setStatus('의도 라우팅 후보: 시각화 안내 우선'); + else setStatus('의도 라우팅 후보를 찾지 못했습니다. 실행 시 추천 액션을 제공합니다.'); }); } -setMode('quick'); +function init() { + bindEvents(); + clearError(); + setMode(appState.currentMode); +} + +init(); diff --git a/bitnet_tools/ui/index.html b/bitnet_tools/ui/index.html index a0cd806..c5eadfa 100644 --- a/bitnet_tools/ui/index.html +++ b/bitnet_tools/ui/index.html @@ -18,6 +18,7 @@

    1) 작업 모드

    빠른 시작은 핵심 분석에 집중하고, 고급 모드는 멀티 분석/모델 실행 옵션을 제공합니다.

    +
      @@ -26,7 +27,7 @@

      2) 입력

      - +
      @@ -34,6 +35,10 @@

      2) 입력

      + + +
      +
      @@ -51,7 +56,12 @@

      고급: 모델 실행

      3) 실행 상태

      -
      대기 중
      +
      대기 중
      +
      +
      + 상세 오류 보기 +
      
      +        
      diff --git a/bitnet_tools/ui/styles.css b/bitnet_tools/ui/styles.css index 372e8b3..b9d38c9 100644 --- a/bitnet_tools/ui/styles.css +++ b/bitnet_tools/ui/styles.css @@ -82,3 +82,35 @@ pre { } .card strong { color: var(--muted); font-size: 12px; } .card span { font-size: 18px; font-weight: 700; } + + +.quick-guide { + margin: 10px 0 0; + padding-left: 18px; + color: var(--muted); +} +.quick-guide li { margin: 4px 0; } + + +.intent-actions { + margin: 8px 0 2px; + min-height: 36px; +} + + +.error-user { + margin-top: 8px; + color: #fecaca; + font-weight: 600; +} +.error-details { + margin-top: 8px; + border: 1px solid #7f1d1d; + border-radius: 8px; + padding: 8px; + background: #1f0b0b; +} +.error-details summary { + cursor: pointer; + color: #fecaca; +} diff --git a/bitnet_tools/web.py b/bitnet_tools/web.py index 413ea57..c8cdd53 100644 --- a/bitnet_tools/web.py +++ b/bitnet_tools/web.py @@ -95,6 +95,9 @@ def run_ollama(model: str, prompt: str) -> str: class Handler(BaseHTTPRequestHandler): + def _error_payload(self, message: str, detail: str | None = None) -> dict[str, str]: + return {"error": message, "error_detail": detail or message} + def _send_json(self, data: dict, status: int = HTTPStatus.OK) -> None: body = json.dumps(data, ensure_ascii=False).encode("utf-8") self.send_response(status) @@ -125,7 +128,7 @@ def do_GET(self) -> None: if route.startswith('/api/charts/jobs/'): job_id = route.split('/')[-1].strip() if not job_id: - return self._send_json({'error': 'job id is required'}, HTTPStatus.BAD_REQUEST) + return self._send_json(self._error_payload('job id is required'), HTTPStatus.BAD_REQUEST) return self._send_json(get_chart_job(job_id)) self.send_error(HTTPStatus.NOT_FOUND) @@ -136,14 +139,14 @@ def do_POST(self) -> None: try: payload = json.loads(raw.decode("utf-8")) if raw else {} except json.JSONDecodeError: - return self._send_json({"error": "invalid json"}, HTTPStatus.BAD_REQUEST) + return self._send_json(self._error_payload('invalid json'), HTTPStatus.BAD_REQUEST) try: if route == "/api/analyze": csv_text = str(payload.get("csv_text", "")) question = str(payload.get("question", "")).strip() if not csv_text.strip(): - return self._send_json({"error": "csv_text is required"}, HTTPStatus.BAD_REQUEST) + return self._send_json(self._error_payload('csv_text is required'), HTTPStatus.BAD_REQUEST) if not question: question = "이 데이터의 핵심 인사이트를 알려줘" result = build_analysis_payload_from_csv_text(csv_text, question) @@ -156,7 +159,7 @@ def do_POST(self) -> None: group_column = str(payload.get("group_column", "")).strip() or None target_column = str(payload.get("target_column", "")).strip() or None if not isinstance(files, list) or not files: - return self._send_json({"error": "files is required"}, HTTPStatus.BAD_REQUEST) + return self._send_json(self._error_payload('files is required'), HTTPStatus.BAD_REQUEST) with tempfile.TemporaryDirectory(prefix="bitnet_multi_") as td: tmp_paths = [] @@ -174,7 +177,7 @@ def do_POST(self) -> None: tmp_paths.append(path) if not tmp_paths: - return self._send_json({"error": "valid csv_text files are required"}, HTTPStatus.BAD_REQUEST) + return self._send_json(self._error_payload('valid csv_text files are required'), HTTPStatus.BAD_REQUEST) result = analyze_multiple_csv( tmp_paths, @@ -194,12 +197,12 @@ def do_POST(self) -> None: model = str(payload.get("model", "")).strip() prompt = str(payload.get("prompt", "")).strip() if not model or not prompt: - return self._send_json({"error": "model and prompt are required"}, HTTPStatus.BAD_REQUEST) + return self._send_json(self._error_payload('model and prompt are required'), HTTPStatus.BAD_REQUEST) answer = run_ollama(model, prompt) return self._send_json({"answer": answer}) except Exception as exc: # runtime surface for UI - return self._send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + return self._send_json(self._error_payload('request failed', str(exc)), HTTPStatus.BAD_REQUEST) self.send_error(HTTPStatus.NOT_FOUND)