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)