diff --git a/bitnet_tools/ui/app.js b/bitnet_tools/ui/app.js index 28f9154..ba19830 100644 --- a/bitnet_tools/ui/app.js +++ b/bitnet_tools/ui/app.js @@ -1,66 +1,145 @@ -const csvFile = document.getElementById('csvFile'); -const csvText = document.getElementById('csvText'); -const question = document.getElementById('question'); -const intent = document.getElementById('intent'); -const intentActions = document.getElementById('intentActions'); -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 modeGuide = document.getElementById('modeGuide'); - -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 = ''; -let currentMode = 'quick'; -const requestState = { question: '', intent: '', route: 'analyze' }; +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 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'; +} + +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; + }); +} + +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; } function saveRequestState(route) { - requestState.question = question?.value || ''; - requestState.intent = intent?.value || ''; - requestState.route = 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', reason: 'empty_intent' }; - - const hasMulti = /(멀티|여러|복수|비교|비교분석|multi)/.test(text); - const hasVisual = /(시각화|차트|그래프|plot|대시보드)/.test(text); - const hasAnalyze = /(분석|요약|인사이트|이상치|진단|핵심)/.test(text); - - if (hasMulti) return { route: 'multi', reason: 'keyword_multi' }; - if (hasVisual) return { route: 'visualize', reason: 'keyword_visualize' }; - if (hasAnalyze) return { route: 'analyze', reason: 'keyword_analyze' }; - return { route: 'unknown', reason: 'no_keyword_match' }; + 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' }; } function renderIntentActions(actions = []) { - if (!intentActions) return; - intentActions.innerHTML = ''; - if (!actions.length) return; - + 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); - intentActions.appendChild(btn); + UI.intentActions.appendChild(btn); }); } @@ -70,7 +149,7 @@ function showFallbackIntentActions() { label: '기본 분석 실행', onClick: () => { setMode('quick'); - if (quickAnalyzeBtn) quickAnalyzeBtn.focus(); + UI.quickAnalyzeBtn?.focus(); setStatus('추천 액션: 기본 분석 실행'); }, }, @@ -78,7 +157,7 @@ function showFallbackIntentActions() { label: '멀티 분석으로 전환', onClick: () => { setMode('advanced'); - if (multiCsvFiles) multiCsvFiles.focus(); + UI.multiCsvFiles?.focus(); setStatus('추천 액션: 멀티 분석 파일을 선택하세요.'); }, }, @@ -86,7 +165,7 @@ function showFallbackIntentActions() { label: '시각화 안내 보기', onClick: () => { setMode('advanced'); - if (dashboardJson) dashboardJson.focus(); + UI.dashboardJson?.focus(); setStatus('추천 액션: 멀티 분석 결과 JSON을 붙여넣고 대시보드를 렌더링하세요.'); }, }, @@ -94,7 +173,7 @@ function showFallbackIntentActions() { } function renderModeGuide(mode) { - if (!modeGuide) return; + if (!UI.modeGuide) return; const steps = mode === 'quick' ? [ '1) CSV 파일을 선택하거나 CSV 텍스트를 붙여넣기', @@ -106,151 +185,162 @@ function renderModeGuide(mode) { '2) 필요 시 모델 태그 입력 후 BitNet 실행', '3) 멀티 CSV/대시보드 고급 기능 활용', ]; - modeGuide.innerHTML = steps.map((step) => `
  • ${step}
  • `).join(''); + UI.modeGuide.innerHTML = steps.map((step) => `
  • ${step}
  • `).join(''); } function setMode(mode) { - currentMode = mode; - const advancedOnly = document.querySelectorAll('.advanced-only'); - advancedOnly.forEach((el) => { + 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); }); - - if (mode === 'quick') { - setStatus('빠른 시작: 입력 → 요청 확인 → 바로 분석'); - } else { - setStatus('고급 모드: 모델 실행/멀티 분석/대시보드를 사용할 수 있습니다.'); - } - + setStatus(mode === 'quick' ? STATUS.quickReady : STATUS.advancedReady); renderModeGuide(mode); } -document.querySelectorAll('.mode-btn').forEach((btn) => { - btn.addEventListener('click', () => setMode(btn.dataset.mode)); -}); - -if (csvFile) { - csvFile.addEventListener('change', async (e) => { - const file = e.target.files?.[0]; - if (!file) return; - csvText.value = await file.text(); - setStatus(`파일 로드 완료: ${file.name}`); - }); -} - -document.querySelectorAll('.chip[data-q]').forEach((chip) => { - chip.addEventListener('click', () => { - question.value = chip.dataset.q; - if (quickAnalyzeBtn) quickAnalyzeBtn.focus(); +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 copyPromptBtn = document.getElementById('copyPrompt'); -if (copyPromptBtn) { - copyPromptBtn.addEventListener('click', async () => { - if (!latestPrompt) return; - await navigator.clipboard.writeText(latestPrompt); - setStatus('프롬프트가 복사되었습니다.'); - }); + 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'}`); - return; + 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); } - - latestPrompt = data.prompt; - summary.textContent = JSON.stringify(data.summary, null, 2); - if (prompt) prompt.textContent = data.prompt; - if (answer) answer.textContent = ''; - setStatus('분석 완료'); } async function runMultiAnalyze() { - const files = [...(multiCsvFiles?.files || [])]; + clearError(); + const files = [...(UI.multiCsvFiles?.files || [])]; if (!files.length) { - dashboardInsights.textContent = '멀티 CSV 파일을 먼저 선택하세요.'; - setStatus('멀티 분석 중단: 파일 없음'); + UI.dashboardInsights.textContent = USER_ERROR.noMultiFiles; + showError(USER_ERROR.noMultiFiles, 'input files is empty'); + setStatus('멀티 분석 중단'); return false; } - setStatus('멀티 분석 중...'); - dashboardInsights.textContent = '멀티 분석 중...'; - const payloadFiles = []; - for (const f of files) { - payloadFiles.push({ name: f.name, csv_text: await f.text() }); - } + 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 res = await fetch('/api/multi-analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const data = await postJson('/api/multi-analyze', { 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'}`); + 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); } +} - dashboardJson.value = JSON.stringify(data, null, 2); - const renderDashboardBtn = document.getElementById('renderDashboardBtn'); - if (renderDashboardBtn) renderDashboardBtn.click(); - setStatus('멀티 분석 완료'); - return true; +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; + } + + 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); + } } async function runByIntent() { + clearError(); renderIntentActions([]); - const intentResult = classifyIntent(intent?.value || ''); + const intentResult = classifyIntent(UI.intent?.value || ''); saveRequestState(intentResult.route); - if (intentResult.route === 'analyze') { - return runAnalyze(); - } + if (intentResult.route === 'analyze') return runAnalyze(); if (intentResult.route === 'multi') { setMode('advanced'); const done = await runMultiAnalyze(); if (!done) { renderIntentActions([ - { - label: '멀티 파일 선택하기', - onClick: () => multiCsvFiles?.focus(), - }, - { - label: '기본 분석으로 진행', - onClick: () => { - setMode('quick'); - runAnalyze(); - }, - }, + { label: '멀티 파일 선택하기', onClick: () => UI.multiCsvFiles?.focus() }, + { label: '기본 분석으로 진행', onClick: () => { setMode('quick'); runAnalyze(); } }, ]); - setStatus('의도 라우팅: 멀티 비교 우선으로 판단했습니다. 파일 선택 후 다시 실행하세요.'); - } else { - setStatus('의도 라우팅: 멀티 비교 우선 작업을 완료했습니다.'); + setStatus('의도 라우팅: 멀티 비교 우선'); } return; } @@ -258,100 +348,68 @@ async function runByIntent() { if (intentResult.route === 'visualize') { setMode('advanced'); renderIntentActions([ - { - label: '대시보드 JSON 입력으로 이동', - onClick: () => dashboardJson?.focus(), - }, - { - label: '먼저 멀티 분석 실행', - onClick: () => multiAnalyzeBtn?.focus(), - }, + { label: '대시보드 JSON 입력으로 이동', onClick: () => UI.dashboardJson?.focus() }, + { label: '먼저 멀티 분석 실행', onClick: () => UI.multiAnalyzeBtn?.focus() }, ]); - setStatus('의도 라우팅: 시각화 안내 우선으로 판단했습니다. 대시보드/멀티 분석 경로를 이용하세요.'); + setStatus('의도 라우팅: 시각화 안내 우선'); return; } showFallbackIntentActions(); - setStatus('의도 해석이 불명확합니다. 아래 추천 액션 중 하나를 선택하세요.'); + showError(USER_ERROR.unknownIntent, `intent="${UI.intent?.value || ''}"`); + setStatus('의도 라우팅 실패'); } -if (analyzeBtn) analyzeBtn.addEventListener('click', runByIntent); -if (quickAnalyzeBtn) quickAnalyzeBtn.addEventListener('click', runByIntent); +function bindEvents() { + document.querySelectorAll('.mode-btn').forEach((btn) => { + btn.addEventListener('click', () => setMode(btn.dataset.mode)); + }); -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 (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}`); + }); + } - 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 }), + document.querySelectorAll('.chip[data-q]').forEach((chip) => { + chip.addEventListener('click', () => { + UI.question.value = chip.dataset.q; + UI.quickAnalyzeBtn?.focus(); }); - const data = await res.json(); - if (answer) answer.textContent = res.ok ? data.answer : (data.error || 'error'); - setStatus(res.ok ? 'BitNet 실행 완료' : `BitNet 실행 실패: ${data.error || 'error'}`); }); -} -const renderDashboardBtn = document.getElementById('renderDashboardBtn'); -if (renderDashboardBtn) { - renderDashboardBtn.addEventListener('click', () => { - dashboardCards.innerHTML = ''; - dashboardInsights.textContent = ''; + UI.copyPromptBtn?.addEventListener('click', async () => { + if (!appState.latestPrompt) return; + await navigator.clipboard.writeText(appState.latestPrompt); + setStatus('프롬프트가 복사되었습니다.'); + }); - let parsed; + UI.analyzeBtn?.addEventListener('click', runByIntent); + UI.quickAnalyzeBtn?.addEventListener('click', runByIntent); + UI.runBtn?.addEventListener('click', runModel); + UI.multiAnalyzeBtn?.addEventListener('click', runMultiAnalyze); + + UI.renderDashboardBtn?.addEventListener('click', () => { + clearError(); try { - parsed = JSON.parse(dashboardJson.value || '{}'); - } catch { - dashboardInsights.textContent = 'JSON 형식이 올바르지 않습니다.'; - setStatus('대시보드 렌더 실패: JSON 형식 오류'); - return; + 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 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); - }); - - const insights = parsed.insights || []; - dashboardInsights.textContent = insights.length - ? insights.map((x, i) => `${i + 1}. ${x}`).join('\n') - : '인사이트 항목이 없습니다.'; - setStatus('대시보드 렌더 완료'); }); -} -if (multiAnalyzeBtn) { - multiAnalyzeBtn.addEventListener('click', runMultiAnalyze); -} - -if (intent) { - intent.addEventListener('input', () => { - if (!intent.value.trim()) { + UI.intent?.addEventListener('input', () => { + if (!UI.intent.value.trim()) { renderIntentActions([]); return; } - const { route } = classifyIntent(intent.value); + const { route } = classifyIntent(UI.intent.value); if (route === 'analyze') setStatus('의도 라우팅 후보: 분석 우선'); else if (route === 'multi') setStatus('의도 라우팅 후보: 멀티 비교 우선'); else if (route === 'visualize') setStatus('의도 라우팅 후보: 시각화 안내 우선'); @@ -359,4 +417,10 @@ if (intent) { }); } -setMode(currentMode); +function init() { + bindEvents(); + clearError(); + setMode(appState.currentMode); +} + +init(); diff --git a/bitnet_tools/ui/index.html b/bitnet_tools/ui/index.html index 8d413d9..c5eadfa 100644 --- a/bitnet_tools/ui/index.html +++ b/bitnet_tools/ui/index.html @@ -57,6 +57,11 @@

    고급: 모델 실행

    3) 실행 상태

    대기 중
    +
    +
    + 상세 오류 보기 +
    
    +        
    diff --git a/bitnet_tools/ui/styles.css b/bitnet_tools/ui/styles.css index b33c2c8..b9d38c9 100644 --- a/bitnet_tools/ui/styles.css +++ b/bitnet_tools/ui/styles.css @@ -96,3 +96,21 @@ pre { 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)